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 ( + + ); +}; + +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")), ]