diff --git a/insights/authentication/permissions.py b/insights/authentication/permissions.py new file mode 100644 index 00000000..73d14a59 --- /dev/null +++ b/insights/authentication/permissions.py @@ -0,0 +1,18 @@ +from rest_framework import permissions +from rest_framework.exceptions import PermissionDenied + +from insights.projects.models import ProjectAuth + + +class ProjectAuthPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if hasattr(obj, "dashboard") and obj.dashboard: + project_id = obj.dashboard.project_id + else: + project_id = obj.project_id + + user = request.user + auth = ProjectAuth.objects.filter(project=project_id, user=user, role=1).first() + if not auth: + raise PermissionDenied("User does not have permission for this project") + return True diff --git a/insights/dashboards/migrations/0002_dashboard_grid.py b/insights/dashboards/migrations/0002_dashboard_grid.py new file mode 100644 index 00000000..03692818 --- /dev/null +++ b/insights/dashboards/migrations/0002_dashboard_grid.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-05-29 20:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dashboards", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="dashboard", + name="grid", + field=models.JSONField(default=list, verbose_name="Grid"), + ), + ] diff --git a/insights/dashboards/models.py b/insights/dashboards/models.py index 629d45d0..e625ed9c 100644 --- a/insights/dashboards/models.py +++ b/insights/dashboards/models.py @@ -33,6 +33,7 @@ class Dashboard(BaseModel, ConfigurableModel): null=True, blank=True, ) + grid = models.JSONField("Grid", default=list) def __str__(self): return f"{self.project.name} - {self.name}" diff --git a/insights/dashboards/serializers.py b/insights/dashboards/serializers.py new file mode 100644 index 00000000..55492db9 --- /dev/null +++ b/insights/dashboards/serializers.py @@ -0,0 +1,50 @@ +from django.conf import settings +from rest_framework import serializers + +from insights.dashboards.models import Dashboard +from insights.widgets.models import Report, Widget + + +class DashboardSerializer(serializers.ModelSerializer): + class Meta: + model = Dashboard + fields = ["uuid", "name", "is_default", "grid"] + + +class DashboardIsDefaultSerializer(serializers.ModelSerializer): + class Meta: + model = Dashboard + fields = ["is_default"] + + +class DashboardReportSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField() + type = serializers.SerializerMethodField() + + def get_url(self, obj): + if obj.config.get("external_url"): + return obj.config["external_url"] + return f"{settings.INSIGHTS_DOMAIN}/dashboards/{obj.widget.dashboard.uuid}/widgets/{obj.widget.uuid}/report/" + + def get_type(self, obj): + if obj.config.get("external_url"): + return "external" + return "internal" + + class Meta: + model = Report + fields = ["url", "type"] + + +class ReportSerializer(serializers.ModelSerializer): + class Meta: + model = Report + fields = "__all__" + + +class DashboardWidgetsSerializer(serializers.ModelSerializer): + report = DashboardReportSerializer() + + class Meta: + model = Widget + fields = "__all__" diff --git a/insights/dashboards/usecases/dashboard_creation.py b/insights/dashboards/usecases/dashboard_creation.py new file mode 100644 index 00000000..de996c9f --- /dev/null +++ b/insights/dashboards/usecases/dashboard_creation.py @@ -0,0 +1,255 @@ +from insights.dashboards.models import Dashboard +from insights.widgets.models import Widget, Report +from django.db import transaction +from insights.dashboards.usecases.exceptions import ( + InvalidDashboardObject, + InvalidWidgetsObject, + InvalidReportsObject, +) + + +class create_atendimento_humano: + def create_dashboard(self, project): + try: + with transaction.atomic(): + atendimento_humano = Dashboard.objects.create( + project=project, + name="Atendimento Humano", + description="Dashboard de atendimento humano", + is_default=False, + grid=[18, 3], + ) + self.create_widgets(atendimento_humano) + + except Exception as exception: + raise InvalidDashboardObject(f"Error creating dashboard: {exception}") + + def create_widgets(self, dashboard_atendimento_humano): + try: + with transaction.atomic(): + pico_de_atendimento = Widget.objects.create( + name="Picos de atendimentos abertos", + w_type="graph_column", + source="chats", + config={ + "end_time": "18:00", + "interval": "60", + "start_time": "07:00", + }, + dashboard=dashboard_atendimento_humano, + position={"rows": [1, 1], "columns": [1, 12]}, + ) + em_andamento = Widget.objects.create( + name="Em andamento", + w_type="card", + source="chats", + config={"operation": "count", "type_result": "executions"}, + dash=dashboard_atendimento_humano, + position={"rows": [2, 2], "columns": [1, 4]}, + ) + Widget.objects.create( + name="Tempo de espera", + w_type="card", + source="chats", + config={"operation": "AVG", "type_result": "executions"}, + dash=dashboard_atendimento_humano, + position={"rows": [2, 2], "columns": [5, 8]}, + ) + encerrados = Widget.objects.create( + name="Encerrados", + w_type="card", + source="chats", + config={"operation": "AVG", "type_result": "executions"}, + dash=dashboard_atendimento_humano, + position={"rows": [2, 2], "columns": [9, 12]}, + ) + Widget.objects.create( + name="Tempo de resposta", + w_type="card", + source="chats", + config={"operation": "count", "type_result": "executions"}, + dash=dashboard_atendimento_humano, + position={"rows": [3, 3], "columns": [1, 4]}, + ) + aguardando_atendimento = Widget.objects.create( + name="Aguardando atendimento", + w_type="card", + source="chats", + config={"operation": "count", "type_result": "executions"}, + dash=dashboard_atendimento_humano, + position={"rows": [3, 3], "columns": [5, 8]}, + ) + Widget.objects.create( + name="Tempo de interação", + w_type="card", + source="chats", + config={"operation": "count", "type_result": "executions"}, + dash=dashboard_atendimento_humano, + position={"rows": [3, 3], "columns": [9, 12]}, + ) + Widget.objects.create( + name="Chats por agente", + w_type="table_dynamic_by_filter", + source="chats", + config={ + "default": { + "icon": "forum:weni-600", + "fields": [ + { + "name": "Agente", + "value": "agent", + "display": True, + "hidden_name": False, + }, + { + "name": "Em andamento", + "value": "open", + "display": True, + "hidden_name": False, + }, + { + "name": "Encerrados", + "value": "close", + "display": True, + "hidden_name": False, + }, + { + "name": "Status", + "value": "status", + "display": True, + "hidden_name": False, + }, + ], + "name_overwrite": "Agentes online", + } + }, + dash=dashboard_atendimento_humano, + position={"rows": [1, 3], "columns": [13, 18]}, + ) + + self.create_reports( + pico_de_atendimento, + em_andamento, + encerrados, + aguardando_atendimento, + ) + except Exception as exception: + raise InvalidWidgetsObject(f"Error creating widgets: {exception}") + + def create_reports( + self, pico_de_atendimento, em_andamento, encerrados, aguardando_atendimento + ): + try: + with transaction.atomic(): + Report.objects.create( + name="Pico de chats abertos por hora", + w_type="graph_column", + source="chats", + config={}, + widget=pico_de_atendimento, + ) + Report.objects.create( + name="Em andamento", + w_type="table_group", + source="chats", + config={}, + widget=em_andamento, + ) + Report.objects.create( + name="Encerrados", + w_type="table_group", + source="chats", + config={}, + widget=encerrados, + ) + Report.objects.create( + name="Aguardando atendimento", + w_type="table_group", + source="chats", + config={}, + widget=aguardando_atendimento, + ) + except Exception as exception: + raise InvalidReportsObject(f"Error creating dashboard: {exception}") + + +class create_resultado_de_fluxo: + def create_dashboard(self, project): + try: + with transaction.atomic(): + dashboard_resultado_de_fluxo = Dashboard.objects.create( + project=project, + name="Resultado de fluxo", + description="Dashboard de resultado de fluxo", + is_default=False, + grid=[12, 3], + ) + self.create_widgets(dashboard_resultado_de_fluxo) + + except Exception as exception: + raise InvalidDashboardObject(f"Error creating dashboard: {exception}") + + def create_widgets(self, dashboard_resultado_de_fluxo): + try: + with transaction.atomic(): + Widget.objects.create( + name="Métrica vazia", + w_type="card", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [1, 1], "columns": [1, 4]}, + ) + Widget.objects.create( + name="Métrica vazia", + w_type="card", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [2, 2], "columns": [1, 4]}, + ) + Widget.objects.create( + name="Métrica vazia", + w_type="card", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [3, 3], "columns": [1, 4]}, + ) + Widget.objects.create( + name="Métrica vazia", + w_type="card", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [1, 1], "columns": [5, 8]}, + ) + Widget.objects.create( + name="Métrica vazia", + w_type="card", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [2, 2], "columns": [5, 8]}, + ) + Widget.objects.create( + name="Métrica vazia", + w_type="card", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [3, 3], "columns": [5, 8]}, + ) + Widget.objects.create( + name="Métrica vazia", + w_type="graph_funnel", + source="", + config={}, + dash=dashboard_resultado_de_fluxo, + position={"rows": [1, 3], "columns": [9, 12]}, + ) + except Exception as exception: + raise InvalidWidgetsObject(f"Error creating widgets: {exception}") + + def create_reports(): + pass diff --git a/insights/dashboards/usecases/dashboard_filters.py b/insights/dashboards/usecases/dashboard_filters.py new file mode 100644 index 00000000..94456047 --- /dev/null +++ b/insights/dashboards/usecases/dashboard_filters.py @@ -0,0 +1,57 @@ +from insights.dashboards.models import Dashboard + + +def get_dash_filters(dash: Dashboard): + if dash.name == "Atendimento humano": + data = { + "tags": { + "type": "select", + "label": "Tags", + "source": "chats_tags", + "depends_on": {"filter": "sectors", "search_param": "sector"}, + "placeholder": "Selecione tags", + }, + "agents": { + "type": "select", + "label": "Agente", + "source": "chats_agents", + "depends_on": {"filter": "sectors", "search_param": None}, + "placeholder": "Selecione agente", + }, + "queues": { + "type": "select", + "label": "Fila", + "source": "chats_queues", + "depends_on": {"filter": "sectors", "search_param": "sector"}, + "placeholder": "Selecione fila", + }, + "contact": { + "type": "input_text", + "label": "Pesquisa por contato", + "placeholder": "Nome ou URN do contato", + }, + "sectors": { + "type": "select", + "label": "Setor", + "source": "chats_sectors", + "placeholder": "Selecione setor", + }, + "ended_at": { + "type": "date_range", + "label": "Data", + "end_sufix": "_before", + "placeholder": None, + "start_sufix": "_after", + }, + } + return data + else: + data = { + "ended_at": { + "type": "date_range", + "end_sufix": "_before", + "placeholder": None, + "start_sufix": "_after", + } + } + return data diff --git a/insights/dashboards/usecases/exceptions.py b/insights/dashboards/usecases/exceptions.py new file mode 100644 index 00000000..cd9ff5eb --- /dev/null +++ b/insights/dashboards/usecases/exceptions.py @@ -0,0 +1,10 @@ +class InvalidDashboardObject(Exception): + pass + + +class InvalidWidgetsObject(Exception): + pass + + +class InvalidReportsObject(Exception): + pass diff --git a/insights/dashboards/utils.py b/insights/dashboards/utils.py new file mode 100644 index 00000000..fde11b2a --- /dev/null +++ b/insights/dashboards/utils.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class DefaultPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = "page_size" + max_page_size = 100 diff --git a/insights/dashboards/viewsets.py b/insights/dashboards/viewsets.py new file mode 100644 index 00000000..a51add65 --- /dev/null +++ b/insights/dashboards/viewsets.py @@ -0,0 +1,93 @@ +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from insights.authentication.permissions import ProjectAuthPermission +from insights.dashboards.models import Dashboard +from insights.dashboards.utils import DefaultPagination +from insights.widgets.models import Widget, Report + +from .serializers import ( + DashboardIsDefaultSerializer, + DashboardSerializer, + DashboardWidgetsSerializer, + DashboardReportSerializer, + ReportSerializer, +) +from .usecases import dashboard_filters + + +class DashboardViewSet( + mixins.ListModelMixin, mixins.UpdateModelMixin, viewsets.GenericViewSet +): + permission_classes = [ProjectAuthPermission] + serializer_class = DashboardSerializer + pagination_class = DefaultPagination + + def get_queryset(self): + project_id = self.request.query_params.get("project", None) + if project_id is not None: + return Dashboard.objects.filter(project_id=project_id) + return Dashboard.objects.none() + + @action(detail=True, methods=["patch"]) + def is_default(self, request, pk=None): + dashboard = self.get_object() + serializer = DashboardIsDefaultSerializer( + dashboard, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @action(detail=True, methods=["get"]) + def list_widgets(self, request, pk=None): + dashboard = self.get_object() + + widgets = Widget.objects.filter(dashboard=dashboard) + + paginator = DefaultPagination() + result_page = paginator.paginate_queryset(widgets, request) + + serializer = DashboardWidgetsSerializer(result_page, many=True) + + return paginator.get_paginated_response(serializer.data) + + @action(detail=True, methods=["get"]) + def filters(self, request, pk=None): + dashboard = self.get_object() + filters = dashboard_filters.get_dash_filters(dashboard) + + return Response(filters) + + @action( + detail=True, methods=["get"], url_path="widgets/(?P[^/.]+)/report" + ) + def get_widget_report(self, request, pk=None, widget_uuid=None): + try: + widget = Widget.objects.get(uuid=widget_uuid, dashboard_id=pk) + report = widget.report + serializer = ReportSerializer(report) + return Response(serializer.data, status=status.HTTP_200_OK) + except Widget.DoesNotExist: + return Response( + {"detail": "Widget not found."}, status=status.HTTP_404_NOT_FOUND + ) + except Report.DoesNotExist: + return Response( + {"detail": "Report not found."}, status=status.HTTP_404_NOT_FOUND + ) + + @action(detail=True, methods=["get"]) + def list_sources(self, request, pk=None): + dashboard = self.get_object() + widgets = dashboard.widgets.all() + + sources = [{"source": widget.source} for widget in widgets] + + paginator = DefaultPagination() + paginated_sources = paginator.paginate_queryset(sources, request) + + return paginator.get_paginated_response(paginated_sources) diff --git a/insights/projects/usecases/create.py b/insights/projects/usecases/create.py index 8441160e..80224247 100644 --- a/insights/projects/usecases/create.py +++ b/insights/projects/usecases/create.py @@ -2,6 +2,11 @@ from .project_dto import ProjectCreationDTO +from insights.dashboards.usecases.dashboard_creation import ( + create_atendimento_humano, + create_resultado_de_fluxo, +) + class ProjectsUseCase: @@ -23,5 +28,6 @@ def create_project(self, project_dto: ProjectCreationDTO) -> Project: timezone=project_dto.timezone, date_format=project_dto.date_format, ) - + create_atendimento_humano.create_dashboard(project) + create_resultado_de_fluxo.create_dashboard(project) return project diff --git a/insights/settings.py b/insights/settings.py index f8c23db9..15bac2ee 100644 --- a/insights/settings.py +++ b/insights/settings.py @@ -33,8 +33,9 @@ AUTH_USER_MODEL = "users.User" -ADMIN_ENABLED = env.bool("ADMIN_ENABLED", default=False) +ADMIN_ENABLED = env.bool("ADMIN_ENABLED", default=True) +INSIGHTS_DOMAIN = env.str(("INSIGHTS_DOMAIN")) # Application definition INSTALLED_APPS = [ @@ -50,6 +51,7 @@ "insights.sources", "insights.users", "insights.widgets", + "rest_framework", ] if ADMIN_ENABLED is True: @@ -91,6 +93,11 @@ DATABASES = {"default": env.db(var="DEFAULT_DATABASE", default="sqlite:///db.sqlite3")} +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 10, +} + # Password validation # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators diff --git a/insights/urls.py b/insights/urls.py index 39914119..d757b89a 100644 --- a/insights/urls.py +++ b/insights/urls.py @@ -16,7 +16,11 @@ """ from django.conf import settings -from django.urls import path +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from insights.dashboards.viewsets import DashboardViewSet +from insights.widgets.viewsets import WidgetListUpdateViewSet urlpatterns = [] @@ -26,3 +30,11 @@ urlpatterns += [ path("admin/", admin.site.urls), ] + +router = DefaultRouter() +router.register(r"widgets", WidgetListUpdateViewSet, basename="widget") +router.register(r"dashboards", DashboardViewSet, basename="dashboard") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/insights/widgets/migrations/0002_remove_widget_report_report.py b/insights/widgets/migrations/0002_remove_widget_report_report.py new file mode 100644 index 00000000..55d7b54b --- /dev/null +++ b/insights/widgets/migrations/0002_remove_widget_report_report.py @@ -0,0 +1,66 @@ +# Generated by Django 5.0.4 on 2024-05-28 18:55 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("widgets", "0001_initial"), + ] + + operations = [ + migrations.RemoveField( + model_name="widget", + name="report", + ), + migrations.CreateModel( + name="Report", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, primary_key=True, serialize=False + ), + ), + ( + "created_on", + models.DateTimeField(auto_now_add=True, verbose_name="Created on"), + ), + ( + "modified_on", + models.DateTimeField(auto_now=True, verbose_name="Modified on"), + ), + ( + "name", + models.CharField(default=None, max_length=255, verbose_name="Name"), + ), + ( + "w_type", + models.CharField( + default=None, max_length=50, verbose_name="Widget Type" + ), + ), + ( + "source", + models.CharField( + default=None, max_length=50, verbose_name="Data Source" + ), + ), + ("config", models.JSONField(verbose_name="Widget Configuration")), + ( + "widget", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="report", + to="widgets.widget", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/insights/widgets/models.py b/insights/widgets/models.py index c220f792..8fe3c05d 100644 --- a/insights/widgets/models.py +++ b/insights/widgets/models.py @@ -1,12 +1,10 @@ +from django.conf import settings from django.db import models from insights.shared.models import BaseModel, ConfigurableModel -class Widget(BaseModel, ConfigurableModel): - dashboard = models.ForeignKey( - "dashboards.Dashboard", related_name="widgets", on_delete=models.CASCADE - ) +class BaseWidget(BaseModel, ConfigurableModel): name = models.CharField( "Name", max_length=255, null=False, blank=False, default=None ) @@ -16,9 +14,27 @@ class Widget(BaseModel, ConfigurableModel): source = models.CharField( "Data Source", max_length=50, null=False, blank=False, default=None ) - position = models.JSONField("Widget position") + # config needs to be required in widget config = models.JSONField("Widget Configuration") - report = models.JSONField("Widget Report") + + class Meta: + abstract = True + + +class Widget(BaseWidget): + dashboard = models.ForeignKey( + "dashboards.Dashboard", related_name="widgets", on_delete=models.CASCADE + ) + position = models.JSONField("Widget position") + + def __str__(self): + return self.name + + +class Report(BaseWidget): + widget = models.OneToOneField( + Widget, related_name="report", on_delete=models.CASCADE + ) def __str__(self): - return self.description + return self.name diff --git a/insights/widgets/serializers.py b/insights/widgets/serializers.py new file mode 100644 index 00000000..823252f3 --- /dev/null +++ b/insights/widgets/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from .models import Widget + + +class WidgetSerializer(serializers.ModelSerializer): + class Meta: + model = Widget + fields = "__all__" diff --git a/insights/widgets/viewsets.py b/insights/widgets/viewsets.py new file mode 100644 index 00000000..4026707a --- /dev/null +++ b/insights/widgets/viewsets.py @@ -0,0 +1,17 @@ +from rest_framework import mixins, viewsets + +from insights.authentication.permissions import ProjectAuthPermission + +from .models import Widget +from .serializers import WidgetSerializer + + +class WidgetListUpdateViewSet( + mixins.ListModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, + mixins.RetrieveModelMixin, +): + permission_classes = [ProjectAuthPermission] + queryset = Widget.objects.all() + serializer_class = WidgetSerializer