diff --git a/client/src/api/interaction.api.ts b/client/src/api/interaction.api.ts new file mode 100644 index 0000000..e7eed90 --- /dev/null +++ b/client/src/api/interaction.api.ts @@ -0,0 +1,23 @@ +import { EventLogBody } from "../types/interaction/interaction.types"; +import api from "./interceptor.api"; + +export const createEventLog = async (eventlog: EventLogBody) => { + try { + const response = await api.post("/api/v1/event-logs/", eventlog); + return response; + } catch (error) { + console.error("Error creating event log:", error); + throw error; + } +}; + +export const fetchEventLogs = async () => { + try { + const response = await api.get(`/api/v1/event-logs/`, { + responseType: "blob", + }); + return response; + } catch (error) { + throw error; + } +}; diff --git a/client/src/components/sidebar/download-button/download-button.component.tsx b/client/src/components/sidebar/download-button/download-button.component.tsx new file mode 100644 index 0000000..3ee9547 --- /dev/null +++ b/client/src/components/sidebar/download-button/download-button.component.tsx @@ -0,0 +1,40 @@ +import { Button, Typography, useTheme } from "@mui/material"; +import { getEventLogs } from "../../../services/interactions.service"; +import { Download } from "react-feather"; + +const DownloadButton = () => { + const theme = useTheme(); + + const handleDownload = async () => { + try { + const response = await getEventLogs(); + + // Create a link element, set its href to the blob URL, and trigger a click to download the file + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", "user_interaction_data.xlsx"); // The file name you want to save as + document.body.appendChild(link); + link.click(); + + // Clean up and remove the link + link.remove(); + window.URL.revokeObjectURL(url); + } catch (error) { + console.error("Error downloading file:", error); + } + }; + + return ( + + ); +}; + +export default DownloadButton; diff --git a/client/src/components/sidebar/sidebar.component.tsx b/client/src/components/sidebar/sidebar.component.tsx index a10bfab..72fb5d3 100644 --- a/client/src/components/sidebar/sidebar.component.tsx +++ b/client/src/components/sidebar/sidebar.component.tsx @@ -23,6 +23,8 @@ import { import { useNavigate } from "react-router-dom"; import { useEffect, useState } from "react"; import { getUserPermissions } from "../../services/user.service"; +import { addEventLog } from "../../services/interactions.service"; +import DownloadButton from "./download-button/download-button.component"; function Sidebar({ open, setOpen }: SidebarParams) { const navigate = useNavigate(); @@ -112,6 +114,17 @@ function Sidebar({ open, setOpen }: SidebarParams) { }} onClick={() => { navigate(item.link); + if ( + (import.meta.env.VITE_ENABLE_TRACKING as string) == "true" + ) { + if (item.name == "Insights") { + addEventLog({ + location: item.name + " - Behavioral Indicators", + }); + } else { + addEventLog({ location: item.name }); + } + } }} > ); })} + + + + + + + + {title} + + + + + + + + + + + ); +} + +export default WordCloudGraph; diff --git a/client/src/components/statistics/statistics.component.tsx b/client/src/components/statistics/statistics.component.tsx index 48bb220..aec3d52 100644 --- a/client/src/components/statistics/statistics.component.tsx +++ b/client/src/components/statistics/statistics.component.tsx @@ -11,6 +11,7 @@ import { getPagesForInsights } from "../../services/pages.service"; import { getLabels } from "../../services/label.service"; import { getTags } from "../../services/tags.service"; import { useToolStore } from "../../states/global.store"; +import { addEventLog } from "../../services/interactions.service"; const Filter = lazy(() => import("./filter/filter.component")); const BehavioralDashboard = lazy( @@ -120,7 +121,14 @@ function Statistics({ open }: SidebarParams) { key={tabItem.name} elevation={0} className="main tabs" - onClick={() => setTab(tabItem.tab)} + onClick={() => { + setTab(tabItem.tab); + if ((import.meta.env.VITE_ENABLE_TRACKING as string) == "true") { + addEventLog({ + location: "Insights - " + tabItem.name, + }); + } + }} sx={{ border: tabItem.tab === tab ? "solid 2px #7f7f7f" : 0, }} diff --git a/client/src/services/interactions.service.ts b/client/src/services/interactions.service.ts new file mode 100644 index 0000000..803bd0f --- /dev/null +++ b/client/src/services/interactions.service.ts @@ -0,0 +1,20 @@ +import { createEventLog, fetchEventLogs } from "../api/interaction.api"; +import { EventLogBody } from "../types/interaction/interaction.types"; + +export const addEventLog = async (event: EventLogBody) => { + try { + const response = await createEventLog(event); + return response.data; + } catch (error: any) { + throw error; + } +}; + +export const getEventLogs = async () => { + try { + const response = await fetchEventLogs(); + return response; + } catch (error: any) { + throw error; + } +}; diff --git a/client/src/types/interaction/interaction.types.ts b/client/src/types/interaction/interaction.types.ts new file mode 100644 index 0000000..a0b753e --- /dev/null +++ b/client/src/types/interaction/interaction.types.ts @@ -0,0 +1,3 @@ +export type EventLogBody = { + location: string; +}; diff --git a/server/chat/admin.py b/server/chat/admin.py index 32a7c9d..562f774 100644 --- a/server/chat/admin.py +++ b/server/chat/admin.py @@ -1,7 +1,51 @@ from django.contrib import admin from .models import Chat, Message, Label +from django.contrib.auth.models import User +import csv +from django.http import HttpResponse +from django.contrib import admin + +def export_as_csv(modeladmin, request, queryset): + meta = modeladmin.model._meta + field_names = [field.name for field in meta.fields] + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta) + writer = csv.writer(response) + + writer.writerow(field_names) + for obj in queryset: + row = [] + for field in field_names: + value = getattr(obj, field) + if isinstance(value, User): # Check if the field is a User instance + value = value.id # Replace the value with the user's ID + row.append(value) + writer.writerow(row) + + return response + +export_as_csv.short_description = "Export Selected as CSV" + +class ModelsChatActions(admin.ModelAdmin): + list_display = [field.name for field in Chat._meta.fields] + actions = [export_as_csv] + search_fields = [field.name for field in Chat._meta.fields if field.name != 'id'] + list_filter = [field.name for field in Chat._meta.fields if field.get_internal_type() in ('CharField', 'BooleanField', 'DateField', 'DateTimeField', 'ForeignKey', 'IntegerField')] + +class ModelsMessagesActions(admin.ModelAdmin): + list_display = [field.name for field in Message._meta.fields] + actions = [export_as_csv] + search_fields = [field.name for field in Message._meta.fields if field.name != 'id'] + list_filter = [field.name for field in Message._meta.fields if field.get_internal_type() in ('CharField', 'BooleanField', 'DateField', 'DateTimeField', 'ForeignKey', 'IntegerField')] + +class ModelsLabelsActions(admin.ModelAdmin): + list_display = [field.name for field in Label._meta.fields] + actions = [export_as_csv] + search_fields = [field.name for field in Label._meta.fields if field.name != 'id'] + list_filter = [field.name for field in Label._meta.fields if field.get_internal_type() in ('CharField', 'BooleanField', 'DateField', 'DateTimeField', 'ForeignKey', 'IntegerField')] # Register your models here. -admin.site.register(Chat) -admin.site.register(Message) -admin.site.register(Label) +admin.site.register(Chat, ModelsChatActions) +admin.site.register(Message, ModelsMessagesActions) +admin.site.register(Label, ModelsLabelsActions) diff --git a/server/chat/views.py b/server/chat/views.py index 4219c75..5c0a51f 100644 --- a/server/chat/views.py +++ b/server/chat/views.py @@ -127,6 +127,14 @@ def post(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, pk, format=None): + try: + label = Label.objects.get(id=pk) + except Label.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + label.delete() + return Response(status=status.HTTP_204_NO_CONTENT) class ChatByPageDetailView(APIView): permission_classes = [IsAuthenticated] diff --git a/server/interactions/__init__.py b/server/interactions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/interactions/admin.py b/server/interactions/admin.py new file mode 100644 index 0000000..9ea8ee7 --- /dev/null +++ b/server/interactions/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin +from .models import EventLog +from django.contrib.auth.models import User +import csv +from django.http import HttpResponse +from django.contrib import admin + +def export_as_csv(modeladmin, request, queryset): + meta = modeladmin.model._meta + field_names = [field.name for field in meta.fields] + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta) + writer = csv.writer(response) + + writer.writerow(field_names) + for obj in queryset: + row = [] + for field in field_names: + value = getattr(obj, field) + if isinstance(value, User): # Check if the field is a User instance + value = value.id # Replace the value with the user's ID + row.append(value) + writer.writerow(row) + + return response + +export_as_csv.short_description = "Export Selected as CSV" + +class ModelsEventLogActions(admin.ModelAdmin): + list_display = [field.name for field in EventLog._meta.fields] + actions = [export_as_csv] + search_fields = [field.name for field in EventLog._meta.fields if field.name != 'id'] + list_filter = [field.name for field in EventLog._meta.fields if field.get_internal_type() in ('CharField', 'BooleanField', 'DateField', 'DateTimeField', 'ForeignKey', 'IntegerField')] + +# Register your models here. +admin.site.register(EventLog, ModelsEventLogActions) \ No newline at end of file diff --git a/server/interactions/apps.py b/server/interactions/apps.py new file mode 100644 index 0000000..67bf651 --- /dev/null +++ b/server/interactions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InteractionsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'interactions' diff --git a/server/interactions/migrations/0001_initial.py b/server/interactions/migrations/0001_initial.py new file mode 100644 index 0000000..7cf12fc --- /dev/null +++ b/server/interactions/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.0.4 on 2024-08-26 10:30 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EventLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('location', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='eventlogs', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/server/interactions/migrations/0002_alter_eventlog_created_at.py b/server/interactions/migrations/0002_alter_eventlog_created_at.py new file mode 100644 index 0000000..1c02f1a --- /dev/null +++ b/server/interactions/migrations/0002_alter_eventlog_created_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.4 on 2024-08-26 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('interactions', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='eventlog', + name='created_at', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/server/interactions/migrations/__init__.py b/server/interactions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/interactions/models.py b/server/interactions/models.py new file mode 100644 index 0000000..5e96699 --- /dev/null +++ b/server/interactions/models.py @@ -0,0 +1,11 @@ +from django.db import models +from django.contrib.auth.models import User +from django.utils import timezone + +class EventLog(models.Model): + location = models.CharField(max_length=100) + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="eventlogs") + + def __str__(self): + return self.location \ No newline at end of file diff --git a/server/interactions/serializers.py b/server/interactions/serializers.py new file mode 100644 index 0000000..308f28d --- /dev/null +++ b/server/interactions/serializers.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import User +from rest_framework import serializers +from .models import EventLog + +class EventLogSerializer(serializers.ModelSerializer): + class Meta: + model = EventLog + fields = ["id", "location", "created_at"] + extra_kwargs = {"user": {"read_only": True}} \ No newline at end of file diff --git a/server/interactions/tests.py b/server/interactions/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/server/interactions/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/server/interactions/urls.py b/server/interactions/urls.py new file mode 100644 index 0000000..3832f6d --- /dev/null +++ b/server/interactions/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('event-logs/', views.EventLogView.as_view(), name='event_log'), +] diff --git a/server/interactions/views.py b/server/interactions/views.py new file mode 100644 index 0000000..89d7b0a --- /dev/null +++ b/server/interactions/views.py @@ -0,0 +1,48 @@ +import pandas as pd +from django.shortcuts import render +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from chat.models import Message, Chat +from .models import EventLog +from .serializers import EventLogSerializer +from rest_framework.response import Response +from django.http import HttpResponse +from rest_framework import status + +class EventLogView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + user = self.request.user + interaction_data = EventLog.objects.filter(user=user).values() + chat_data = Message.objects.filter(user=user).values('id', 'request', 'response', 'chat__title', 'chat__page__label', 'created_at') + + # Convert the data to a pandas DataFrame + interaction_df = pd.DataFrame(list(interaction_data)) + interaction_df['created_at'] = interaction_df['created_at'].astype(str) + + chat_df = pd.DataFrame(list(chat_data)) + chat_df['created_at'] = chat_df['created_at'].astype(str) + + # Create an Excel file in memory + response = HttpResponse(content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = f'attachment; filename={user.username}_data.xlsx' + + with pd.ExcelWriter(response, engine='openpyxl') as writer: + chat_df.to_excel(writer, index=False, sheet_name='ChatData') + interaction_df.to_excel(writer, index=False, sheet_name='InteractionData') + + return response + + def post(self, request, *args, **kwargs): + try: + data = { + 'location': request.data.get('location'), + } + except Exception: + return Response(status=status.HTTP_400_BAD_REQUEST) + serializer = EventLogSerializer(data=data) + if serializer.is_valid(): + serializer.save(user=self.request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file diff --git a/server/pages/admin.py b/server/pages/admin.py index 7363d51..e1f28db 100644 --- a/server/pages/admin.py +++ b/server/pages/admin.py @@ -1,6 +1,45 @@ from django.contrib import admin from .models import Tag, Page +from django.contrib.auth.models import User +import csv +from django.http import HttpResponse +from django.contrib import admin + +def export_as_csv(modeladmin, request, queryset): + meta = modeladmin.model._meta + field_names = [field.name for field in meta.fields] + + response = HttpResponse(content_type='text/csv') + response['Content-Disposition'] = 'attachment; filename={}.csv'.format(meta) + writer = csv.writer(response) + + writer.writerow(field_names) + for obj in queryset: + row = [] + for field in field_names: + value = getattr(obj, field) + if isinstance(value, User): # Check if the field is a User instance + value = value.id # Replace the value with the user's ID + row.append(value) + writer.writerow(row) + + return response + +export_as_csv.short_description = "Export Selected as CSV" + +class ModelsTagActions(admin.ModelAdmin): + list_display = [field.name for field in Tag._meta.fields] + actions = [export_as_csv] + search_fields = [field.name for field in Tag._meta.fields if field.name != 'id'] + list_filter = [field.name for field in Tag._meta.fields if field.get_internal_type() in ('CharField', 'BooleanField', 'DateField', 'DateTimeField', 'ForeignKey', 'IntegerField')] + +class ModelsPageActions(admin.ModelAdmin): + list_display = [field.name for field in Page._meta.fields] + actions = [export_as_csv] + search_fields = [field.name for field in Page._meta.fields if field.name != 'id'] + list_filter = [field.name for field in Page._meta.fields if field.get_internal_type() in ('CharField', 'BooleanField', 'DateField', 'DateTimeField', 'ForeignKey', 'IntegerField')] + # Register your models here. -admin.site.register(Tag) -admin.site.register(Page) \ No newline at end of file +admin.site.register(Tag, ModelsTagActions) +admin.site.register(Page, ModelsPageActions) \ No newline at end of file diff --git a/server/pages/views.py b/server/pages/views.py index 5d1ed1f..c152ca0 100644 --- a/server/pages/views.py +++ b/server/pages/views.py @@ -111,4 +111,12 @@ def post(self, request, *args, **kwargs): if serializer.is_valid(): serializer.save(user=self.request.user) return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, pk, format=None): + try: + tag = Tag.objects.get(id=pk) + except Tag.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + tag.delete() + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt index 19a98aa..287dfcd 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -14,4 +14,6 @@ gunicorn whitenoise exchangelib nltk -scikit-learn \ No newline at end of file +scikit-learn +pandas +openpyxl \ No newline at end of file diff --git a/server/server/settings.py b/server/server/settings.py index 2ca9879..6b516a5 100644 --- a/server/server/settings.py +++ b/server/server/settings.py @@ -69,6 +69,7 @@ 'pages', 'insights', 'deploy_management', + 'interactions', 'rest_framework', 'corsheaders' ] diff --git a/server/server/urls.py b/server/server/urls.py index eb2865b..dcabc43 100644 --- a/server/server/urls.py +++ b/server/server/urls.py @@ -14,4 +14,5 @@ path("api/v1/", include("pages.urls")), path("api/v1/", include("users.urls")), path("api/v1/", include("insights.urls")), + path("api/v1/", include("interactions.urls")), ]