diff --git a/.github/workflows/build_docker.yml b/.github/workflows/build_docker.yml
new file mode 100644
index 0000000..c8e0da7
--- /dev/null
+++ b/.github/workflows/build_docker.yml
@@ -0,0 +1,105 @@
+name: Build Docker Image
+
+on:
+ workflow_call:
+ outputs:
+ server_image_tag:
+ description: "The tag of the server image that was built"
+ value: ${{ jobs.build.outputs.server_image_tag }}
+ client_image_tag:
+ description: "The tag of the client image that was built"
+ value: ${{ jobs.build.outputs.client_image_tag }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - dockerfile: ./docker/client/Dockerfile
+ image: ghcr.io/ls1intum/thaii/client
+ context: .
+ path: client
+ - dockerfile: ./docker/server/Dockerfile
+ image: ghcr.io/ls1intum/thaii/server
+ context: .
+ path: server
+ outputs:
+ server_image_tag: "${{ steps.output-tag-server.outputs.server_image_tag }}"
+ client_image_tag: "${{ steps.output-tag-client.outputs.client_image_tag }}"
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Get changed files in the client folder
+ id: changed-files-client-folder
+ uses: tj-actions/changed-files@v44
+ with:
+ files: client/**
+
+ - name: Get changed files in the server folder
+ id: changed-files-server-folder
+ uses: tj-actions/changed-files@v44
+ with:
+ files: server/**
+
+ - name: Log in to the Container registry
+ if: ${{ (steps.changed-files-client-folder.outputs.any_changed == 'true') || (steps.changed-files-server-folder.outputs.any_changed == 'true') }}
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up QEMU
+ if: ${{ (steps.changed-files-client-folder.outputs.any_changed == 'true') || (steps.changed-files-server-folder.outputs.any_changed == 'true') }}
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: all
+
+ - name: Install Docker Buildx
+ if: ${{ (steps.changed-files-client-folder.outputs.any_changed == 'true') || (steps.changed-files-server-folder.outputs.any_changed == 'true') }}
+ id: buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ matrix.image }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=ref,event=branch
+ type=ref,event=pr
+
+ - name: Build and push Docker Image
+ uses: docker/build-push-action@v5
+ if: ${{ (steps.changed-files-client-folder.outputs.any_changed == 'true' && matrix.path == 'client') || (steps.changed-files-server-folder.outputs.any_changed == 'true' && matrix.path == 'server') }}
+ with:
+ context: ${{ matrix.context }}
+ file: ${{ matrix.dockerfile }}
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ build-args: |
+ "VITE_API_URL=${{ vars.VITE_API_URL }}"
+ "VITE_ENABLE_TRACKING"=${{ vars.VITE_ENABLE_TRACKING }}"
+
+ - id: output-tag-client
+ run: |
+ if [[ "${{ matrix.path }}" == "client" ]] && [[ "${{ steps.changed-files-client-folder.outputs.any_changed }}" == "true" ]]; then
+ echo "client_image_tag=${{ steps.meta.outputs.version }}" >> "$GITHUB_OUTPUT"
+ elif [[ "${{ matrix.path }}" == "client" ]]; then
+ echo "client_image_tag=latest" >> "$GITHUB_OUTPUT"
+ fi
+
+ - id: output-tag-server
+ run: |
+ if [[ "${{ matrix.path }}" == "server" ]] && [[ "${{ steps.changed-files-server-folder.outputs.any_changed }}" == "true" ]]; then
+ echo "server_image_tag=${{ steps.meta.outputs.version }}" >> "$GITHUB_OUTPUT"
+ elif [[ "${{ matrix.path }}" == "server" ]]; then
+ echo "server_image_tag=latest" >> "$GITHUB_OUTPUT"
+ fi
\ No newline at end of file
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ad7489b..15f0707 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -26,8 +26,8 @@ jobs:
- name: Push Docker Images to GHCR
run: |
- docker push ghcr.io/${{ github.repository }}/client:latest
- docker push ghcr.io/${{ github.repository }}/server:latest
+ docker push ghcr.io/ls1intum/thaii/client:latest
+ docker push ghcr.io/ls1intum/thaii/server:latest
deploy:
name: Deploy Application
@@ -35,46 +35,63 @@ jobs:
needs: build
steps:
+ - name: SSH to VM and Execute Docker-Compose Down
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ script: |
+ docker compose -f compose.yml --env-file=.env down --remove-orphans --rmi all
+
- name: Checkout Code
uses: actions/checkout@v3
- - name: Install SSH Client
- run: sudo apt-get update && sudo apt-get install -y openssh-client
-
- - name: Add SSH Key
- uses: webfactory/ssh-agent@v0.8.1
- with:
- ssh-private-key: ${{ secrets.SSH_KEY }}
-
- name: Copy Files to Server
- run: |
- scp -o StrictHostKeyChecking=no ./compose.yml ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }}:~/compose.yml
- scp -o StrictHostKeyChecking=no -r ./letsencrypt ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }}:~/letsencrypt
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ script: |
+ scp -o StrictHostKeyChecking=no ./compose.yml ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }}:~/compose.yml
+ scp -o StrictHostKeyChecking=no -r ./letsencrypt ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }}:~/letsencrypt
- name: Set Up Environment Variables
- run: |
- ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} << 'EOF'
- echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env
- echo "DEBUG=${{ secrets.DEBUG }}" >> .env
- echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env
- echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
- echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
- echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
- echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env
- echo "EMAIL_USE_TLS=${{ secrets.EMAIL_USE_TLS }}" >> .env
- echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}" >> .env
- echo "EMAIL_HOST_USER=${{ secrets.EMAIL_HOST_USER }}" >> .env
- echo "EMAIL_HOST_PASSWORD=${{ secrets.EMAIL_HOST_PASSWORD }}" >> .env
- echo "DEFAULT_FROM_EMAIL=${{ secrets.DEFAULT_FROM_EMAIL }}" >> .env
- echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}" >> .env
- echo "DJANGO_SUPERUSER_USERNAME=${{ secrets.DJANGO_SUPERUSER_USERNAME }}" >> .env
- echo "DJANGO_SUPERUSER_PASSWORD=${{ secrets.DJANGO_SUPERUSER_PASSWORD }}" >> .env
- echo "DJANGO_SUPERUSER_EMAIL=${{ secrets.DJANGO_SUPERUSER_EMAIL }}" >> .env
- EOF
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ script: |
+ ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} << 'EOF'
+ touch .env
+ echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env
+ echo "DEBUG=${{ secrets.DEBUG }}" >> .env
+ echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env
+ echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
+ echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
+ echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
+ echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env
+ echo "EMAIL_USE_TLS=${{ secrets.EMAIL_USE_TLS }}" >> .env
+ echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}" >> .env
+ echo "EMAIL_HOST_USER=${{ secrets.EMAIL_HOST_USER }}" >> .env
+ echo "EMAIL_HOST_PASSWORD=${{ secrets.EMAIL_HOST_PASSWORD }}" >> .env
+ echo "DEFAULT_FROM_EMAIL=${{ secrets.DEFAULT_FROM_EMAIL }}" >> .env
+ echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}" >> .env
+ echo "DJANGO_SUPERUSER_USERNAME=${{ secrets.DJANGO_SUPERUSER_USERNAME }}" >> .env
+ echo "DJANGO_SUPERUSER_PASSWORD=${{ secrets.DJANGO_SUPERUSER_PASSWORD }}" >> .env
+ echo "DJANGO_SUPERUSER_EMAIL=${{ secrets.DJANGO_SUPERUSER_EMAIL }}" >> .env
+ EOF
- - name: Deploy on Server
- run: |
- ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "mkdir -p ~/"
- ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "touch ~/letsencrypt/acme.json && chmod 600 ~/letsencrypt/acme.json"
- ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "docker login ghcr.io -u ${{ github.actor }} --password-stdin <<< ${{ secrets.GITHUB_TOKEN }}"
- ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "docker compose pull && docker compose up -d && docker compose logs"
+ - name: SSH to VM and Execute Docker-Compose Up
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ script: |
+ ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "mkdir -p ~/"
+ ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "touch ~/letsencrypt/acme.json && chmod 600 ~/letsencrypt/acme.json"
+ ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "docker login ghcr.io -u ${{ github.actor }} --password-stdin <<< ${{ secrets.GITHUB_TOKEN }}"
+ ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_DOMAIN }} "docker compose pull && docker compose up -d && docker compose logs"
diff --git a/.github/workflows/deploy_docker.yml b/.github/workflows/deploy_docker.yml
new file mode 100644
index 0000000..039841d
--- /dev/null
+++ b/.github/workflows/deploy_docker.yml
@@ -0,0 +1,105 @@
+name: Deploy Docker Image
+
+on:
+ workflow_call:
+ inputs:
+ environment:
+ required: true
+ type: string
+ server_image_tag:
+ default: "latest"
+ type: string
+ client_image_tag:
+ default: "latest"
+ type: string
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: SSH to VM and Execute Docker-Compose Down
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ proxy_host: ${{ vars.DEPLOYMENT_GATEWAY_HOST }}
+ proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
+ proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
+ proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
+ script: |
+ docker compose -f compose.yml --env-file=.env down --remove-orphans --rmi all
+
+ - name: Checkout Code
+ uses: actions/checkout@v3
+
+ - name: Copy Docker Compose File From Repo to VM Host
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ proxy_host: ${{ vars.DEPLOYMENT_GATEWAY_HOST }}
+ proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
+ proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
+ proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
+ source: "./compose.yml"
+ target: /home/${{ secrets.SERVER_USER }}
+
+ - name: Copy Letsencrypt File From Repo to VM Host
+ uses: appleboy/scp-action@v0.1.7
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ proxy_host: ${{ vars.DEPLOYMENT_GATEWAY_HOST }}
+ proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
+ proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
+ proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
+ source: "./letsencrypt"
+ target: /home/${{ secrets.SERVER_USER }}
+
+ - name: Set Up Environment Variables
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ proxy_host: ${{ vars.DEPLOYMENT_GATEWAY_HOST }}
+ proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
+ proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
+ proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
+ script: |
+ touch .env
+ echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env
+ echo "DEBUG=${{ secrets.DEBUG }}" >> .env
+ echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env
+ echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
+ echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
+ echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
+ echo "POSTGRES_HOST=${{ secrets.POSTGRES_HOST }}" >> .env
+ echo "EMAIL_USE_TLS=${{ secrets.EMAIL_USE_TLS }}" >> .env
+ echo "EMAIL_HOST=${{ secrets.EMAIL_HOST }}" >> .env
+ echo "EMAIL_HOST_USER=${{ secrets.EMAIL_HOST_USER }}" >> .env
+ echo "EMAIL_HOST_PASSWORD=${{ secrets.EMAIL_HOST_PASSWORD }}" >> .env
+ echo "DEFAULT_FROM_EMAIL=${{ secrets.DEFAULT_FROM_EMAIL }}" >> .env
+ echo "EMAIL_PORT=${{ secrets.EMAIL_PORT }}" >> .env
+ echo "DJANGO_SUPERUSER_USERNAME=${{ secrets.DJANGO_SUPERUSER_USERNAME }}" >> .env
+ echo "DJANGO_SUPERUSER_PASSWORD=${{ secrets.DJANGO_SUPERUSER_PASSWORD }}" >> .env
+ echo "DJANGO_SUPERUSER_EMAIL=${{ secrets.DJANGO_SUPERUSER_EMAIL }}" >> .env
+
+ - name: SSH to VM and Execute Docker-Compose Up
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.SERVER_DOMAIN }}
+ username: ${{ secrets.SERVER_USER }}
+ key: ${{ secrets.SSH_KEY }}
+ proxy_host: ${{ vars.DEPLOYMENT_GATEWAY_HOST }}
+ proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
+ proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
+ proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
+ script: |
+ mkdir -p ~/
+ touch ~/letsencrypt/acme.json && chmod 600 ~/letsencrypt/acme.json
+ docker login ghcr.io -u ${{ github.actor }} --password-stdin <<< ${{ secrets.GITHUB_TOKEN }}
+ docker compose pull && docker compose up -d && docker compose logs
diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml
new file mode 100644
index 0000000..b3e462c
--- /dev/null
+++ b/.github/workflows/prod.yml
@@ -0,0 +1,19 @@
+name: Build and Deploy to Prod
+
+on:
+ push:
+ branches: [develop]
+
+jobs:
+ build-prod-container:
+ uses: ./.github/workflows/build_docker.yml
+ secrets: inherit
+ deploy-prod-container:
+ needs: build-prod-container
+ uses: ./.github/workflows/deploy_docker.yml
+ secrets: inherit
+ with:
+ environment: Production
+ server_image_tag: "latest"
+ client_image_tag: "latest"
+
\ No newline at end of file
diff --git a/client/src/api/chat.api.ts b/client/src/api/chat.api.ts
index 935473b..8a5567e 100644
--- a/client/src/api/chat.api.ts
+++ b/client/src/api/chat.api.ts
@@ -1,6 +1,8 @@
import { ChatBody } from "../types/chatbot/chatbot.types";
import api from "./interceptor.api";
+// Fetch all chats for an user
+// @limit: Number of loaded chats. Default number is 5.
export const fetchChats = async (limit: number) => {
try {
if (!limit) {
@@ -13,6 +15,7 @@ export const fetchChats = async (limit: number) => {
}
};
+// Fetch the count of all chats for an user
export const fetchChatsCount = async () => {
try {
const response = await api.get(`/api/v1/chats/count/`);
@@ -23,6 +26,7 @@ export const fetchChatsCount = async () => {
}
};
+// Fetch chat by chat ID for an user
export const fetchChatById = async (chatId: number) => {
try {
const response = await api.get(`/api/v1/chats/${chatId}/`);
@@ -32,6 +36,8 @@ export const fetchChatById = async (chatId: number) => {
}
};
+// Fetch chats assigned to a page for a user
+// @pageId: Id of a existing page
export const fetchChatByPageId = async (pageId: number) => {
try {
const response = await api.get(`/api/v1/chats/page/${pageId}/`);
@@ -42,6 +48,8 @@ export const fetchChatByPageId = async (pageId: number) => {
}
};
+// Create new chat
+// @chat: title: string, page: string, labels: string[]
export const createChat = async (chat: ChatBody) => {
try {
const response = await api.post("/api/v1/chats/", chat);
@@ -52,6 +60,9 @@ export const createChat = async (chat: ChatBody) => {
}
};
+// Change existing chat of user
+// @chat: title: string, page: string, labels: string[]
+// @id: id of chat which should be changed
export const changeChat = async (id: number, chat: ChatBody) => {
try {
const response = await api.put(`/api/v1/chats/${id}/`, chat);
@@ -62,6 +73,8 @@ export const changeChat = async (id: number, chat: ChatBody) => {
}
};
+// Delete existing chat of user
+// @id: id of chat to delete
export const deleteChat = async (id: number) => {
try {
const response = await api.delete(`/api/v1/chats/${id}/`);
diff --git a/client/src/api/insights.api.ts b/client/src/api/insights.api.ts
index 739600d..07ad278 100644
--- a/client/src/api/insights.api.ts
+++ b/client/src/api/insights.api.ts
@@ -1,6 +1,13 @@
import { FilterBody } from "../types/statistics/statistics.types";
import api from "./interceptor.api";
+//@filter:
+// - dateRange: Dates included in analysis
+// - page: Pages included in analysis
+// - labels: Labels included in analysis
+// - tags: Tags included in analysis
+
+// Fetch number of total chats
export const fetchTotalChats = async (filter: FilterBody) => {
try {
const response = await api.post(`/api/v1/insights/total-chats/`, filter);
@@ -10,6 +17,7 @@ export const fetchTotalChats = async (filter: FilterBody) => {
}
};
+// Fetch number of total messages
export const fetchTotalMessages = async (filter: FilterBody) => {
try {
const response = await api.post(`/api/v1/insights/total-messages/`, filter);
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 (
+ }
+ sx={{ background: theme.palette.primary.dark, textTransform: "none" }}
+ onClick={handleDownload}
+ >
+ Download Data
+
+ );
+};
+
+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 });
+ }
+ }
}}
>
);
})}
+
+
+
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/constants/routes.constant.ts b/client/src/constants/routes.constant.ts
index 159ac6e..9c46be6 100644
--- a/client/src/constants/routes.constant.ts
+++ b/client/src/constants/routes.constant.ts
@@ -1,2 +1,3 @@
export const LOGIN = "api/v1/token/"
+export const REGISTER = "api/v1/token/"
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/compose.yml b/compose.yml
index 235162c..6d9002e 100644
--- a/compose.yml
+++ b/compose.yml
@@ -25,7 +25,7 @@ services:
backend:
container_name: server
hostname: server
- image: ghcr.io/ls1intum/Thaii/server:latest
+ image: ghcr.io/ls1intum/thaii/server:latest
build:
context: .
dockerfile: docker/server/Dockerfile
@@ -62,7 +62,7 @@ services:
client:
container_name: client
hostname: client
- image: ghcr.io/ls1intum/Thaii/client:latest
+ image: ghcr.io/ls1intum/thaii/client:latest
build:
context: .
dockerfile: docker/client/Dockerfile
diff --git a/docker/client/Dockerfile b/docker/client/Dockerfile
index d76e21c..e4e1452 100644
--- a/docker/client/Dockerfile
+++ b/docker/client/Dockerfile
@@ -1,16 +1,18 @@
FROM node:alpine
ARG VITE_API_URL
+ARG VITE_ENABLE_TRACKING
ENV VITE_API_URL=$VITE_API_URL
+ENV VITE_ENABLE_TRACKING=$VITE_ENABLE_TRACKING
WORKDIR /client
-COPY ./client/package*.json ./
+COPY ../../client/package*.json ./
RUN npm install
-COPY ./client .
+COPY ../../client .
RUN npm run build
diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile
index d5e3784..5d3ebfe 100644
--- a/docker/server/Dockerfile
+++ b/docker/server/Dockerfile
@@ -1,14 +1,25 @@
+# Use Python slim image
FROM python:3.12.4-slim-bookworm
+# Set the working directory inside the Docker image
WORKDIR /server
-COPY ./server .
+# Copy only the requirements first to leverage caching
+COPY ../../server/requirements.txt .
+# Create and activate the virtual environment, then install dependencies
RUN python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip && \
- /opt/venv/bin/pip install -r requirements.txt --no-cache-dir && \
- chmod +x /server/entrypoint.sh
+ /opt/venv/bin/pip install -r requirements.txt --no-cache-dir
+# Copy the rest of the server code into the Docker image
+COPY ../../server .
+
+# Ensure entrypoint script has execute permissions
+RUN chmod +x /server/entrypoint.sh
+
+# Expose the application port
EXPOSE 8000
-CMD [ "/server/entrypoint.sh" ]
\ No newline at end of file
+# Run the entrypoint script
+CMD [ "/server/entrypoint.sh" ]
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 ef3cc45..5c0a51f 100644
--- a/server/chat/views.py
+++ b/server/chat/views.py
@@ -9,7 +9,6 @@
from rest_framework.response import Response
from rest_framework import status
-# Create your views here.
class CreateUserView(generics.CreateAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
@@ -128,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/insights/services/commonWordAnalyzer.py b/server/insights/services/commonWordAnalyzer.py
index 6c31b07..a1b8803 100644
--- a/server/insights/services/commonWordAnalyzer.py
+++ b/server/insights/services/commonWordAnalyzer.py
@@ -11,6 +11,7 @@
cachedStopWords = stopwords.words("english")
+# Handler to find common words in messages of users
def handle_array_common_word(array, variant):
textList = []
formattedTextList = []
diff --git a/server/insights/views.py b/server/insights/views.py
index d6fcfe6..82db368 100644
--- a/server/insights/views.py
+++ b/server/insights/views.py
@@ -19,6 +19,7 @@
import datetime
from django.contrib.postgres.aggregates import StringAgg
+# Get current date and time
now_ = now()
class TotalChatsView(APIView):
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 ae42c46..c152ca0 100644
--- a/server/pages/views.py
+++ b/server/pages/views.py
@@ -8,7 +8,6 @@
from chat.models import Chat
from rest_framework.permissions import IsAuthenticated
-# Create your views here.
class PageListView(APIView):
permission_classes = [IsAuthenticated]
@@ -112,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 2a1d7aa..6b516a5 100644
--- a/server/server/settings.py
+++ b/server/server/settings.py
@@ -47,7 +47,7 @@
}
STATICFILES_STORAGE = ('whitenoise.storage.CompressedManifestStaticFilesStorage')
-STATIC_URL = '/static/'
+STATIC_URL = '/api/static/'
STATIC_ROOT = os.path.join(BASE_DIR, "templates")
SIMPLE_JWT = {
@@ -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")),
]