Initial commit

This commit is contained in:
2025-05-17 00:57:08 +02:00
commit fe69bdbc94
71 changed files with 6585 additions and 0 deletions

45
.editorconfig Normal file
View File

@ -0,0 +1,45 @@
# EditorConfig is a standardized configuration file that helps define
# consistent coding styles across different editors and IDEs
# https://editorconfig.org/
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Python files
[*.py]
indent_size = 4
# HTML and Django/Jinja2 template files
[*.{html,htm}]
indent_size = 2
# Allow prettier to format Django/Jinja templates properly
# The following comment options can be used in individual files if needed:
# <!-- prettier-ignore -->
# {# prettier-ignore #}
# CSS, JavaScript, and JSON files
[*.{css,scss,js,json}]
indent_size = 2
# Markdown files
[*.md]
trim_trailing_whitespace = false
# YAML files
[*.{yml,yaml}]
indent_size = 2
# Makefile (requires tabs)
[Makefile]
indent_style = tab
# Docker-related files
[{Dockerfile,docker-compose.yml}]
indent_size = 2

409
.gitignore vendored Normal file
View File

@ -0,0 +1,409 @@
# Created by https://www.toptal.com/developers/gitignore/api/django,python,node
# Edit at https://www.toptal.com/developers/gitignore?templates=django,python,node
### Django ###
*.log
*.pot
*.pyc
__pycache__/
local_settings.py
db.sqlite3
db.sqlite3-journal
media
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/
### Django.Python Stack ###
# Byte-compiled / optimized / DLL files
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
# Django stuff:
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Node ###
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Python ###
# Byte-compiled / optimized / DLL files
# C extensions
# Distribution / packaging
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
# Installer logs
# Unit test / coverage reports
# Translations
# Django stuff:
# Flask stuff:
# Scrapy stuff:
# Sphinx documentation
# PyBuilder
# Jupyter Notebook
# IPython
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
# Celery stuff
# SageMath parsed files
# Environments
# Spyder project settings
# Rope project settings
# mkdocs documentation
# mypy
# Pyre type checker
# pytype static type analyzer
# Cython debug symbols
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/django,python,node
# Custom ignores from here on
# Local environment variables
*Zone.Identifier
examples/
**/migrations/[0-9]**.py

49
.prettierignore Normal file
View File

@ -0,0 +1,49 @@
# Python virtual environment
.venv/
venv/
env/
# Static files collected by Django
staticfiles/
dashboard_project/staticfiles/
# Media files
media/
dashboard_project/media/
# Python cache files
__pycache__/
*.py[cod]
*$py.class
# Database files
*.sqlite3
*.db
# Django migration files
*/migrations/*.py
!*/migrations/__init__.py
# Node modules (if you use npm/yarn for frontend)
node_modules/
# Distribution / packaging
dist/
build/
*.egg-info/
# Log files
*.log
# Environment variables
.env
.env.*
# Docker
docker-compose.override.yml
# IDE specific files
.idea/
.vscode/
*.swp
*.swo

30
.prettierrc Normal file
View File

@ -0,0 +1,30 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": false,
"overrides": [
{
"files": [
"*.html"
],
"options": {
"parser": "jinja-template"
}
}
],
"plugins": [
"prettier-plugin-jinja-template"
]
}

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.13

47
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,47 @@
{
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[python]": {
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true
},
"[toml]": {
"editor.defaultFormatter": "tamasfe.even-better-toml"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"emmet.includeLanguages": {
"django-html": "html",
"jinja-html": "html"
},
"emmet.syntaxProfiles": {
"html": {
"inline_break": 2
}
},
"files.associations": {
"*.html": "html"
},
"html.format.wrapAttributes": "auto",
"html.format.wrapLineLength": 100,
"notebook.codeActionsOnSave": {
"notebook.source.fixAll": "explicit",
"notebook.source.organizeImports": "explicit"
},
"notebook.formatOnSave.enabled": true,
"prettier.requireConfig": true,
}

24
Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# Dockerfile
FROM python:3.13-slim
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV DJANGO_SETTINGS_MODULE=dashboard_project.settings
# Set work directory
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN uv pip install -e .
# Copy project
COPY . .
# Collect static files
RUN python manage.py collectstatic --noinput
# Run gunicorn
CMD ["gunicorn", "dashboard_project.wsgi:application", "--bind", "0.0.0.0:8000"]

126
IMPLEMENTATION_SUMMARY.md Normal file
View File

@ -0,0 +1,126 @@
# Chat Analytics Dashboard: Implementation Summary
## Core Features Implemented
1. **Multi-Tenant Architecture**:
- Companies have isolated data and user access
- Users belong to specific companies
- Role-based permissions (admin, company admin, regular user)
2. **Data Management**:
- CSV file upload and processing
- Data source management
- Chat session records with comprehensive metadata
3. **Dashboard Visualization**:
- Interactive charts using Plotly.js
- Key metrics and KPIs
- Time-series analysis
- Geographic distribution
- Sentiment analysis
- Category distribution
4. **Search and Analysis**:
- Full-text search across chat sessions
- Filtering by various attributes
- Detailed view of individual chat sessions
- Transcript viewing
5. **User Management**:
- User registration and authentication
- Profile management
- Password change functionality
- Role assignment
6. **Admin Interface**:
- Company management
- User administration
- Data source oversight
- System-wide configuration
7. **Responsive Design**:
- Mobile-friendly interface using Bootstrap 5
- Consistent layout and navigation
- Accessible UI components
## Technical Implementation
### Backend (Django)
- **Custom User Model**: Extended for company association and roles
- **Database Models**: Structured for efficient data storage and queries
- **View Logic**: Separation of concerns with dedicated view functions
- **Form Handling**: Validated data input and file uploads
- **Data Processing**: CSV parsing and structured storage
- **Template Context**: Prepared data for frontend rendering
- **URL Routing**: Clean URL structure
- **Access Control**: Permission checks throughout
### Frontend
- **Bootstrap 5**: For responsive layout and UI components
- **Plotly.js**: For interactive charts and visualizations
- **jQuery**: For AJAX functionality
- **Font Awesome**: For icons
- **Custom CSS**: For styling enhancements
### Data Flow
1. **Upload Process**:
- File validation
- CSV parsing
- Data normalization
- Record creation
- Association with company
2. **Dashboard Generation**:
- Data aggregation
- Statistical calculations
- Chart data preparation
- JSON serialization for frontend
3. **User Authentication**:
- Login/registration handling
- Session management
- Permission checks
- Access control based on company
### Deployment Configuration
- **Docker**: Containerization for consistent deployment
- **Docker Compose**: Multi-container orchestration
- **Nginx**: Web server and static file serving
- **PostgreSQL**: Production-ready database
- **Gunicorn**: WSGI HTTP server
## API Structure
While the current implementation does not have a formal REST API, the foundation is in place for adding one in the future:
1. **Dashboard API**: Already implemented for chart data (JSON responses)
2. **Data Source API**: Potential endpoint for uploading data programmatically
3. **Chat Session API**: Could expose data for external integration
## Testing and Development
- **Sample Data Generation**: Management command to create test data
- **Local Development Setup**: Easy configuration with sqlite
- **Production Deployment**: Docker-based for scalability
## Security Considerations
- **Authentication**: Django's secure authentication system
- **Data Isolation**: Company-specific queries prevent data leakage
- **Password Management**: Secure password handling
- **CSRF Protection**: Django's built-in CSRF protection
- **Input Validation**: Form validation for all user inputs
## Future Extensions
The architecture supports easy extension for:
1. **API Integration**: Direct connection to chat platforms
2. **Real-time Updates**: WebSockets for live dashboard updates
3. **Advanced Analytics**: Machine learning integration
4. **Customizable Reports**: Report generation and scheduling
5. **Enhanced Visualization**: More chart types and interactive features

67
PRETTIER_SETUP.md Normal file
View File

@ -0,0 +1,67 @@
# Prettier for Django/Jinja Templates
This project uses Prettier with the `prettier-plugin-jinja-template` plugin to format HTML templates with Django/Jinja syntax.
## Setup
To use Prettier with your Django templates, you'll need to install Prettier and the Jinja template plugin:
```bash
# Using npm
npm install --save-dev prettier prettier-plugin-jinja-template
# Or using yarn
yarn add --dev prettier prettier-plugin-jinja-template
```
## Usage
Once installed, you can format your Django templates using:
```bash
# Format a specific file
npx prettier --write path/to/template.html
# Format all HTML files
npx prettier --write "**/*.html"
```
### Without install
If you don't want to install the plugin, you can use the following command:
```bash
npx prettier --plugin=prettier-plugin-jinja-template --parser=jinja-template --write **/*.html
```
## VSCode Integration
For VSCode users, install the Prettier extension and add these settings to your `.vscode/settings.json`:
```json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"prettier.requireConfig": true
}
```
## Ignoring Parts of Templates
If you need to prevent Prettier from formatting a section of your template:
```html
{# prettier-ignore #}
<div>
This section will not be formatted
by Prettier.
</div>
<!-- prettier-ignore -->
<div>
This works too.
</div>
```

126
PROJECT_OVERVIEW.md Normal file
View File

@ -0,0 +1,126 @@
# Chat Analytics Dashboard Project
## Overview
This Django project creates a multi-tenant dashboard application for analyzing chat session data. Companies can upload their chat data (in CSV format) and view analytics and metrics through an interactive dashboard. The application supports user authentication, role-based access control, and separate data isolation for different companies.
## Project Structure
The project consists of two main Django apps:
1. **accounts**: Handles user authentication, company management, and user roles
2. **dashboard**: Manages data sources, chat sessions, and dashboard visualization
## Key Features
- **Multi-company Support**: Each company has their own private dashboards and data
- **User Management**: Different user roles (admin, company admin, regular user)
- **CSV File Upload**: Upload and process CSV files containing chat session data
- **Interactive Dashboard**: Visualize chat data with charts and metrics
- **Search Functionality**: Find specific chat sessions based on various criteria
- **Data Exploration**: Drill down into individual chat sessions for detailed analysis
## Setup and Installation
### Requirements
- Python 3.8+
- Django 4.0+
- Other dependencies listed in `requirements.txt`
### Installation Steps
1. Clone the repository
2. Set up a virtual environment
3. Install dependencies with `pip install -r requirements.txt`
4. Run database migrations with `python manage.py migrate`
5. Create a superuser with `python manage.py createsuperuser`
6. Start the development server with `python manage.py runserver`
### Creating Sample Data
To quickly populate the application with sample data, run:
```sh
python manage.py create_sample_data
```
This will create:
- An admin user (username: admin, password: admin123)
- Three sample companies
- Company admin users for each company
- Regular users for each company
- Sample chat data for each company
- Default dashboards for each company
## Models
### Accounts App
- **CustomUser**: Extends Django's User model with company association and role
- **Company**: Represents a company with users and data sources
### Dashboard App
- **DataSource**: Represents an uploaded CSV file with chat data
- **ChatSession**: Stores individual chat session data parsed from CSV
- **Dashboard**: Allows configuration of custom dashboards with selected data sources
## Usage Flow
1. **Admin Setup**:
- Admin creates companies
- Admin creates users and assigns them to companies
2. **Company Admin**:
- Uploads CSV files with chat data
- Creates and configures dashboards
- Manages company users
3. **Regular Users**:
- View dashboards
- Search and explore chat data
- Analyze chat metrics
## CSV Format
The application expects CSV files with the following columns:
- **session_id**: Unique identifier for each chat session
- **start_time**: When the chat session started
- **end_time**: When the chat session ended
- **ip_address**: User's IP address
- **country**: User's country
- **language**: Language used in the chat
- **messages_sent**: Number of messages in the conversation
- **sentiment**: Sentiment analysis result (Positive, Neutral, Negative)
- **escalated**: Whether the chat was escalated
- **forwarded_hr**: Whether the chat was forwarded to HR
- **full_transcript**: Complete chat transcript
- **avg_response_time**: Average response time in seconds
- **tokens**: Number of tokens used (for AI chat systems)
- **tokens_eur**: Cost of tokens in EUR
- **category**: Chat category or topic
- **initial_msg**: First message from the user
- **user_rating**: User satisfaction rating
## Deployment
For production deployment, the project includes:
- **Dockerfile**: For containerizing the application
- **docker-compose.yml**: For orchestrating the application with PostgreSQL and Nginx
- **Nginx Configuration**: For serving the application and static files
## Future Enhancements
- **API Integration**: Direct integration with chat systems
- **Real-time Updates**: Live dashboard updates as new chats occur
- **Advanced Analytics**: More detailed and customizable metrics
- **Export Functionality**: Export reports and analysis
- **Customizable Themes**: Company-specific branding
## Support
For any issues or questions, please create an issue in the repository or contact the project maintainers.

243
QUICK_START_GUIDE.md Normal file
View File

@ -0,0 +1,243 @@
# Chat Analytics Dashboard: Quick Start Guide
## Getting Started
This guide will help you quickly set up and start using the Chat Analytics Dashboard.
### Installation
#### Option 1: Local Development
1. **Clone the repository**:
```sh
git clone <repository-url>
cd dashboard_project
```
2. **Set up a virtual environment**:
```sh
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
```
3. **Install dependencies**: # from pyproject.toml
```sh
uv pip install -r requirements.txt
```
4. **Set up the database**:
```sh
python manage.py migrate
```
5. **Create admin user**:
```sh
python manage.py createsuperuser
```
6. **Start the development server**:
```sh
python manage.py runserver
```
7. **Access the application**:
Open your browser and go to <http://127.0.0.1:8000/>
#### Option 2: Docker Deployment
1. **Clone the repository**:
```sh
git clone <repository-url>
cd dashboard_project
```
2. **Build and start the containers**:
```sh
docker-compose up -d --build
```
3. **Create admin user**:
```sh
docker-compose exec web python manage.py createsuperuser
```
4. **Access the application**:
Open your browser and go to <http://localhost/>
### Creating Sample Data (Optional)
To quickly populate the system with sample data:
```sh
python manage.py create_sample_data
```
This will create:
- Admin user (username: admin, password: admin123)
- Three companies with users
- Sample chat data and dashboards
## Basic Usage
### Admin Tasks
1. **Access Admin Panel**:
- Go to <http://localhost/admin/>
- Login with your admin credentials
2. **Create a Company**:
- Go to Companies > Add Company
- Fill in the company details and save
3. **Create Users**:
- Go to Users > Add User
- Fill in user details
- Assign the user to a company
- Set appropriate permissions (staff status, company admin)
### Company Admin Tasks
1. **Login to Dashboard**:
- Go to <http://localhost/>
- Login with your company admin credentials
2. **Upload Chat Data**:
- Click on "Upload Data" in the sidebar
- Fill in the data source details
- Select a CSV file containing chat data
- Click "Upload"
3. **Create a Dashboard**:
- Click on "New Dashboard" in the sidebar
- Fill in the dashboard details
- Select data sources to include
- Click "Create Dashboard"
### Regular User Tasks
1. **View Dashboard**:
- Login with your user credentials
- The dashboard will show automatically
- Select different dashboards from the sidebar
2. **Search Chat Sessions**:
- Click on "Search" in the top navigation
- Enter search terms
- Use filters to refine results
3. **View Session Details**:
- In search results, click the eye icon for a session
- View complete session information and transcript
## CSV Format
Your CSV files should include the following columns:
| Column | Description | Type |
| ----------------- | ------------------------------- | -------- |
| session_id | Unique ID for the chat | String |
| start_time | Session start time | Datetime |
| end_time | Session end time | Datetime |
| ip_address | User's IP address | String |
| country | User's country | String |
| language | Chat language | String |
| messages_sent | Number of messages | Integer |
| sentiment | Sentiment analysis result | String |
| escalated | Whether chat was escalated | Boolean |
| forwarded_hr | Whether chat was sent to HR | Boolean |
| full_transcript | Complete chat text | Text |
| avg_response_time | Average response time (seconds) | Float |
| tokens | Number of tokens used | Integer |
| tokens_eur | Cost in EUR | Float |
| category | Chat category | String |
| initial_msg | First user message | Text |
| user_rating | User satisfaction rating | String |
Example CSV row:
```csv
acme_1,2023-05-01 10:30:00,2023-05-01 10:45:00,192.168.1.1,USA,English,10,Positive,FALSE,FALSE,"User: Hello\nAgent: Hi there!",2.5,500,0.01,Support,Hello I need help,Good
```
## Dashboard Features
### Overview Panel
The main dashboard shows:
- Total chat sessions
- Average response time
- Total tokens used
- Total cost
### Charts
The dashboard includes:
- **Sessions Over Time**: Line chart showing chat volume trends
- **Sentiment Analysis**: Pie chart of positive/negative/neutral chats
- **Top Countries**: Bar chart of user countries
- **Categories**: Distribution of chat categories
### Data Source Details
View details for each data source:
- Upload date and time
- Total sessions
- Source description
- List of all chat sessions from the source
### Session Details
For each chat session, you can view:
- Session metadata (time, location, etc.)
- Full chat transcript
- Performance metrics
- User sentiment and rating
## Troubleshooting
### CSV Upload Issues
If your CSV upload fails:
- Ensure all required columns are present
- Check date formats (should be YYYY-MM-DD HH:MM:SS)
- Verify boolean values (TRUE/FALSE, Yes/No, 1/0)
- Check for special characters in text fields
### Access Issues
If you can't access certain features:
- Verify your user role (admin, company admin, or regular user)
- Ensure you're assigned to the correct company
- Check if you're trying to access another company's data
### Empty Dashboard
If your dashboard is empty:
- Verify that data sources have been uploaded
- Check that the dashboard is configured to use those data sources
- Ensure the CSV was processed successfully
## Getting Help
If you encounter any issues:
- Check the documentation
- Contact your system administrator
- File an issue in the project repository

131
README.md Normal file
View File

@ -0,0 +1,131 @@
# Chat Analytics Dashboard
A Django application that creates an analytics dashboard for chat session data. The application allows different companies to have their own dashboards and view their own data.
## Features
- Multi-company support with user authentication
- CSV file upload and processing
- Interactive dashboard with charts and visualizations
- Detailed data views for chat sessions
- Search functionality to find specific chat sessions
- Admin interface for managing users and companies
- Responsive design using Bootstrap 5
## Requirements
- Python 3.13+
- Django 5.0+
- PostgreSQL (optional, SQLite is fine for development)
- Other dependencies listed in [`pyproject.toml`](pyproject.toml)
## Setup
### Local Development
1. Clone the repository:
```sh
git clone <repository-url>
cd dashboard_project
```
2. Create a virtual environment and activate it:
```sh
uv venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
```
3. Install dependencies:
```sh
uv pip install -r requirements.txt
```
4. Run migrations:
```sh
uv run python manage.py makemigrations
uv run python manage.py migrate
```
5. Create a superuser:
```sh
uv run python manage.py createsuperuser
```
6. Run the development server:
```sh
uv run python manage.py runserver
```
7. Access the application at <http://127.0.0.1:8000/>
### Using Docker
1. Clone the repository:
```sh
git clone <repository-url>
cd dashboard_project
```
2. Build and run with Docker Compose:
```sh
docker-compose up -d --build
```
3. Create a superuser:
```sh
docker-compose exec web python manage.py createsuperuser
```
4. Access the application at <http://localhost/>
## Usage
1. Login as the superuser you created.
2. Go to the admin interface (<http://localhost/admin/>) and create companies and users.
3. Assign users to companies.
4. Upload CSV files for each company.
5. View the analytics dashboard.
## CSV File Format
The CSV file should contain the following columns:
- session_id: Unique identifier for the chat session
- start_time: When the session started (datetime)
- end_time: When the session ended (datetime)
- ip_address: IP address of the user
- country: Country of the user
- language: Language used in the conversation
- messages_sent: Number of messages in the conversation (integer)
- sentiment: Sentiment analysis of the conversation (string)
- escalated: Whether the conversation was escalated (boolean)
- forwarded_hr: Whether the conversation was forwarded to HR (boolean)
- full_transcript: Full transcript of the conversation (text)
- avg_response_time: Average response time in seconds (float)
- tokens: Total number of tokens used (integer)
- tokens_eur: Cost of tokens in EUR (float)
- category: Category of the conversation (string)
- initial_msg: First message from the user (text)
- user_rating: User rating of the conversation (string)
## Future Enhancements
- API integration for real-time data
- More advanced visualizations
- Custom reports
- Export functionality
- Theme customization
- User access control with more granular permissions
## License
This project is licensed under the MIT License - see the LICENSE file for details.

View File

@ -0,0 +1 @@
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1 @@
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,77 @@
# accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserChangeForm, CustomUserCreationForm
from .models import Company, CustomUser
class CustomUserAdmin(UserAdmin):
add_form = CustomUserCreationForm
form = CustomUserChangeForm
model = CustomUser
list_display = ("username", "email", "company", "is_company_admin", "is_staff")
list_filter = ("is_staff", "is_active", "company", "is_company_admin")
fieldsets = (
(None, {"fields": ("username", "email", "password")}),
(
"Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
("Company", {"fields": ("company", "is_company_admin")}),
("Important dates", {"fields": ("last_login", "date_joined")}),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": (
"username",
"email",
"password1",
"password2",
"company",
"is_company_admin",
"is_staff",
"is_active",
),
},
),
)
search_fields = ("username", "email", "company__name")
ordering = ("username",)
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
if obj.is_superuser and not obj.company:
default_company, created = Company.objects.get_or_create(
name="Default Organization",
defaults={"description": "Default company for new superusers."},
)
obj.company = default_company
obj.is_company_admin = True # Optionally make the superuser an admin of this default company
obj.save()
class CompanyAdmin(admin.ModelAdmin):
list_display = ("name", "created_at", "get_employee_count")
search_fields = ("name", "description")
def get_employee_count(self, obj):
return obj.employees.count()
get_employee_count.short_description = "Employees"
admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(Company, CompanyAdmin)

View File

@ -0,0 +1,8 @@
# accounts/apps.py
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"

View File

@ -0,0 +1,45 @@
# accounts/forms.py
from django import forms
from django.contrib.auth.forms import UserCreationForm
from .models import Company, CustomUser
class CustomUserCreationForm(UserCreationForm):
"""Form for creating new users"""
class Meta:
model = CustomUser
fields = ("username", "email", "password1", "password2")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add help text for fields
self.fields["email"].required = True
self.fields["email"].help_text = "Required. Enter a valid email address."
class CustomUserChangeForm(forms.ModelForm):
"""Form for updating users"""
class Meta:
model = CustomUser
fields = ("username", "email", "company", "is_company_admin")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Only staff members can change company and admin status
if not kwargs.get("instance") or not kwargs.get("instance").is_staff:
if "company" in self.fields:
self.fields["company"].disabled = True
if "is_company_admin" in self.fields:
self.fields["is_company_admin"].disabled = True
class CompanyForm(forms.ModelForm):
"""Form for creating and updating companies"""
class Meta:
model = Company
fields = ("name", "description")

View File

@ -0,0 +1 @@
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,34 @@
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class CustomUser(AbstractUser):
"""Custom user model to extend the default Django user"""
company = models.ForeignKey(
"Company",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="employees",
)
is_company_admin = models.BooleanField(default=False)
def __str__(self):
return self.username
class Company(models.Model):
"""Model for companies that will access the dashboard"""
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = "Companies"

View File

View File

@ -0,0 +1,28 @@
# accounts/urls.py
from allauth.account import views as allauth_views
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
urlpatterns = [
path(
"login/",
auth_views.LoginView.as_view(template_name="accounts/login.html"),
name="login",
),
path("logout/", allauth_views.LogoutView.as_view(), name="logout"), # Use allauth logout view
path("register/", views.register_view, name="register"),
path("profile/", views.profile_view, name="profile"),
path(
"password_change/",
auth_views.PasswordChangeView.as_view(template_name="accounts/password_change.html"),
name="password_change",
),
path(
"password_change/done/",
auth_views.PasswordChangeDoneView.as_view(template_name="accounts/password_change_done.html"),
name="password_change_done",
),
]

View File

@ -0,0 +1,72 @@
# accounts/views.py
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from .forms import CompanyForm, CustomUserCreationForm
from .models import Company, CustomUser
def register_view(request):
"""View for user registration"""
if request.method == "POST":
form = CustomUserCreationForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
messages.success(request, "Registration successful.")
return redirect("dashboard")
else:
messages.error(request, "Registration failed. Please correct the errors.")
else:
form = CustomUserCreationForm()
return render(request, "accounts/register.html", {"form": form})
@login_required
def profile_view(request):
"""View for user profile"""
user = request.user
company = user.company
context = {
"user": user,
"company": company,
}
return render(request, "accounts/profile.html", context)
@staff_member_required
def company_create_view(request):
"""View for creating companies (admin only)"""
if request.method == "POST":
form = CompanyForm(request.POST)
if form.is_valid():
company = form.save()
messages.success(request, f"Company '{company.name}' created successfully.")
return redirect("admin:accounts_company_changelist")
else:
messages.error(request, "Failed to create company. Please correct the errors.")
else:
form = CompanyForm()
return render(request, "admin/accounts/company/create.html", {"form": form})
@staff_member_required
def company_users_view(request, company_id):
"""View for managing users in a company (admin only)"""
company = Company.objects.get(pk=company_id)
users = CustomUser.objects.filter(company=company)
context = {
"company": company,
"users": users,
}
return render(request, "admin/accounts/company/users.html", context)

16
dashboard_project/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for dashboard_project project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard_project.settings")
application = get_asgi_application()

View File

@ -0,0 +1 @@
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,65 @@
# dashboard/admin.py
from django.contrib import admin
from .models import ChatSession, Dashboard, DataSource
class DataSourceAdmin(admin.ModelAdmin):
list_display = ("name", "company", "uploaded_at", "get_session_count")
list_filter = ("company", "uploaded_at")
search_fields = ("name", "description", "company__name")
ordering = ("-uploaded_at",)
def get_session_count(self, obj):
return obj.chat_sessions.count()
get_session_count.short_description = "Sessions"
class ChatSessionAdmin(admin.ModelAdmin):
list_display = (
"session_id",
"get_company",
"start_time",
"end_time",
"country",
"language",
"sentiment",
)
list_filter = (
"data_source__company",
"start_time",
"country",
"language",
"sentiment",
"escalated",
"forwarded_hr",
)
search_fields = (
"session_id",
"country",
"language",
"initial_msg",
"full_transcript",
)
ordering = ("-start_time",)
def get_company(self, obj):
return obj.data_source.company.name
get_company.short_description = "Company"
get_company.admin_order_field = "data_source__company__name"
class DashboardAdmin(admin.ModelAdmin):
list_display = ("name", "company", "created_at", "updated_at")
list_filter = ("company", "created_at")
search_fields = ("name", "description", "company__name")
filter_horizontal = ("data_sources",)
ordering = ("-updated_at",)
admin.site.register(DataSource, DataSourceAdmin)
admin.site.register(ChatSession, ChatSessionAdmin)
admin.site.register(Dashboard, DashboardAdmin)

View File

@ -0,0 +1,8 @@
# dashboard/apps.py
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "dashboard"

View File

@ -0,0 +1,49 @@
# dashboard/forms.py
from django import forms
from .models import Dashboard, DataSource
class DataSourceUploadForm(forms.ModelForm):
"""Form for uploading CSV files"""
class Meta:
model = DataSource
fields = ["name", "description", "file"]
def __init__(self, *args, **kwargs):
self.company = kwargs.pop("company", None)
super().__init__(*args, **kwargs)
def save(self, commit=True):
instance = super().save(commit=False)
if self.company:
instance.company = self.company
if commit:
instance.save()
return instance
class DashboardForm(forms.ModelForm):
"""Form for creating and editing dashboards"""
class Meta:
model = Dashboard
fields = ["name", "description", "data_sources"]
def __init__(self, *args, **kwargs):
self.company = kwargs.pop("company", None)
super().__init__(*args, **kwargs)
if self.company:
self.fields["data_sources"].queryset = DataSource.objects.filter(company=self.company)
def save(self, commit=True):
instance = super().save(commit=False)
if self.company:
instance.company = self.company
if commit:
instance.save()
self.save_m2m()
return instance

View File

@ -0,0 +1,2 @@
# dashboard/management/__init__.py
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,2 @@
# dashboard/management/commands/__init__.py
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,277 @@
# dashboard/management/commands/create_sample_data.py
import csv
import io
import random
from datetime import datetime, timedelta
from accounts.models import Company
from dashboard.models import ChatSession, Dashboard, DataSource
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.utils import timezone
User = get_user_model()
class Command(BaseCommand):
help = "Create sample data for testing"
def handle(self, *args, **kwargs):
self.stdout.write("Creating sample data...")
# Create admin user if it doesn't exist
if not User.objects.filter(username="admin").exists():
admin_user = User.objects.create_superuser(username="admin", email="admin@example.com", password="admin123")
self.stdout.write(self.style.SUCCESS(f"Created admin user: {admin_user.username}"))
else:
admin_user = User.objects.get(username="admin")
self.stdout.write(f"Admin user already exists: {admin_user.username}")
# Create companies
companies = []
company_names = ["Acme Inc.", "TechCorp", "GlobalServices"]
for name in company_names:
company, created = Company.objects.get_or_create(
name=name, defaults={"description": f"Sample company: {name}"}
)
companies.append(company)
if created:
self.stdout.write(self.style.SUCCESS(f"Created company: {company.name}"))
else:
self.stdout.write(f"Company already exists: {company.name}")
# Create users for each company
for i, company in enumerate(companies):
# Company admin
username = f"admin_{company.name.lower().replace(' ', '_')}"
if not User.objects.filter(username=username).exists():
user = User.objects.create_user(
username=username,
email=f"{username}@example.com",
password="password123",
company=company,
is_company_admin=True,
)
self.stdout.write(self.style.SUCCESS(f"Created company admin: {user.username}"))
# Regular users
for j in range(2):
username = f"user_{company.name.lower().replace(' ', '_')}_{j + 1}"
if not User.objects.filter(username=username).exists():
user = User.objects.create_user(
username=username,
email=f"{username}@example.com",
password="password123",
company=company,
)
self.stdout.write(self.style.SUCCESS(f"Created user: {user.username}"))
# Create sample data for each company
for company in companies:
self._create_sample_data_for_company(company)
self.stdout.write(self.style.SUCCESS("Sample data created successfully!"))
def _create_sample_data_for_company(self, company):
# Create sample CSV data
csv_data = self._generate_sample_csv_data(company.name)
# Create data source
data_source_name = f"{company.name} Chat Data"
try:
data_source = DataSource.objects.get(name=data_source_name, company=company)
self.stdout.write(f"Data source already exists: {data_source.name}")
except DataSource.DoesNotExist:
# Create file from CSV data
csv_file = ContentFile(csv_data.encode("utf-8"))
data_source = DataSource.objects.create(
name=data_source_name,
description=f"Sample chat data for {company.name}",
company=company,
)
data_source.file.save(f"{company.name.lower().replace(' ', '_')}_chat_data.csv", csv_file)
self.stdout.write(self.style.SUCCESS(f"Created data source: {data_source.name}"))
# Parse CSV data and create chat sessions
reader = csv.DictReader(io.StringIO(csv_data))
for row in reader:
# Convert datetime strings to datetime objects
start_time = datetime.strptime(row["start_time"], "%Y-%m-%d %H:%M:%S")
end_time = datetime.strptime(row["end_time"], "%Y-%m-%d %H:%M:%S")
# Convert boolean strings to actual booleans
escalated = row["escalated"].lower() in ["true", "yes", "1", "t", "y"]
forwarded_hr = row["forwarded_hr"].lower() in [
"true",
"yes",
"1",
"t",
"y",
]
# Create chat session
ChatSession.objects.create(
data_source=data_source,
session_id=row["session_id"],
start_time=timezone.make_aware(start_time),
end_time=timezone.make_aware(end_time),
ip_address=row["ip_address"],
country=row["country"],
language=row["language"],
messages_sent=int(row["messages_sent"]),
sentiment=row["sentiment"],
escalated=escalated,
forwarded_hr=forwarded_hr,
full_transcript=row["full_transcript"],
avg_response_time=float(row["avg_response_time"]),
tokens=int(row["tokens"]),
tokens_eur=float(row["tokens_eur"]),
category=row["category"],
initial_msg=row["initial_msg"],
user_rating=row["user_rating"],
)
self.stdout.write(self.style.SUCCESS(f"Created {reader.line_num} chat sessions"))
# Create default dashboard
dashboard_name = f"{company.name} Dashboard"
try:
dashboard = Dashboard.objects.get(name=dashboard_name, company=company)
self.stdout.write(f"Dashboard already exists: {dashboard.name}")
except Dashboard.DoesNotExist:
dashboard = Dashboard.objects.create(
name=dashboard_name,
description=f"Default dashboard for {company.name}",
company=company,
)
dashboard.data_sources.add(data_source)
self.stdout.write(self.style.SUCCESS(f"Created dashboard: {dashboard.name}"))
def _generate_sample_csv_data(self, company_name):
"""Generate sample CSV data for a company"""
rows = []
headers = [
"session_id",
"start_time",
"end_time",
"ip_address",
"country",
"language",
"messages_sent",
"sentiment",
"escalated",
"forwarded_hr",
"full_transcript",
"avg_response_time",
"tokens",
"tokens_eur",
"category",
"initial_msg",
"user_rating",
]
# Sample data for generating random values
countries = [
"USA",
"UK",
"Germany",
"France",
"Spain",
"Italy",
"Japan",
"Australia",
"Canada",
"Brazil",
]
languages = ["English", "Spanish", "German", "French", "Japanese", "Portuguese"]
sentiments = [
"Positive",
"Negative",
"Neutral",
"Very Positive",
"Very Negative",
]
categories = ["Support", "Sales", "Technical", "Billing", "General"]
ratings = ["Excellent", "Good", "Average", "Poor", "Terrible", ""]
# Generate rows
num_rows = random.randint(50, 100)
for i in range(num_rows):
# Generate random dates in the last 30 days
end_date = datetime.now() - timedelta(days=random.randint(0, 30))
start_date = end_date - timedelta(minutes=random.randint(5, 60))
# Generate random IP address
ip = ".".join(str(random.randint(0, 255)) for _ in range(4))
# Random country and language
country = random.choice(countries)
language = random.choice(languages)
# Random message count
messages_sent = random.randint(3, 20)
# Random sentiment
sentiment = random.choice(sentiments)
# Random escalation and forwarding
escalated = random.random() < 0.2 # 20% chance of escalation
forwarded_hr = random.random() < 0.1 # 10% chance of forwarding to HR
# Generate a sample transcript
transcript = (
"User: Hello, I need help with my account.\n"
"Agent: Hello! I'd be happy to help. What seems to be the issue?\n"
"User: I can't log in to my account.\n"
"Agent: I understand. Let me help you reset your password."
)
# Random response time, tokens, and cost
avg_response_time = round(random.uniform(0.5, 10.0), 2)
tokens = random.randint(100, 2000)
tokens_eur = round(tokens * 0.00002, 4) # Example rate: €0.00002 per token
# Random category
category = random.choice(categories)
# Initial message
initial_msg = "Hello, I need help with my account."
# Random rating
user_rating = random.choice(ratings)
# Create row
row = {
"session_id": f"{company_name.lower().replace(' ', '_')}_{i + 1}",
"start_time": start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_time": end_date.strftime("%Y-%m-%d %H:%M:%S"),
"ip_address": ip,
"country": country,
"language": language,
"messages_sent": str(messages_sent),
"sentiment": sentiment,
"escalated": str(escalated),
"forwarded_hr": str(forwarded_hr),
"full_transcript": transcript,
"avg_response_time": str(avg_response_time),
"tokens": str(tokens),
"tokens_eur": str(tokens_eur),
"category": category,
"initial_msg": initial_msg,
"user_rating": user_rating,
}
rows.append(row)
# Write to CSV string
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=headers)
writer.writeheader()
writer.writerows(rows)
return output.getvalue()

View File

@ -0,0 +1 @@
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,57 @@
# dashboard/models.py
from accounts.models import Company
from django.db import models
class DataSource(models.Model):
"""Model for uploaded data sources (CSV files)"""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
file = models.FileField(upload_to="data_sources/")
uploaded_at = models.DateTimeField(auto_now_add=True)
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="data_sources")
def __str__(self):
return self.name
class ChatSession(models.Model):
"""Model to store parsed chat session data from CSV"""
data_source = models.ForeignKey(DataSource, on_delete=models.CASCADE, related_name="chat_sessions")
session_id = models.CharField(max_length=255)
start_time = models.DateTimeField(null=True, blank=True)
end_time = models.DateTimeField(null=True, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
country = models.CharField(max_length=100, blank=True)
language = models.CharField(max_length=50, blank=True)
messages_sent = models.IntegerField(default=0)
sentiment = models.CharField(max_length=50, blank=True)
escalated = models.BooleanField(default=False)
forwarded_hr = models.BooleanField(default=False)
full_transcript = models.TextField(blank=True)
avg_response_time = models.FloatField(null=True, blank=True)
tokens = models.IntegerField(default=0)
tokens_eur = models.FloatField(null=True, blank=True)
category = models.CharField(max_length=100, blank=True)
initial_msg = models.TextField(blank=True)
user_rating = models.CharField(max_length=50, blank=True)
def __str__(self):
return f"Session {self.session_id}"
class Dashboard(models.Model):
"""Model for custom dashboards that can be created by users"""
name = models.CharField(max_length=255)
description = models.TextField(blank=True)
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="dashboards")
data_sources = models.ManyToManyField(DataSource, related_name="dashboards")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name

View File

@ -0,0 +1,2 @@
# dashboard/templatetags/__init__.py
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,56 @@
# dashboard/templatetags/dashboard_extras.py
from django import template
register = template.Library()
@register.filter
def split(value, delimiter):
"""Split a string into a list based on the delimiter"""
return value.split(delimiter)
@register.filter
def get_item(dictionary, key):
"""Get an item from a dictionary using the key"""
return dictionary.get(key)
@register.filter
def truncate_middle(value, max_length):
"""Truncate a string in the middle, keeping the beginning and end"""
if len(value) <= max_length:
return value
# Calculate how many characters to keep at the start and end
half_max = max_length // 2
start = value[:half_max]
end = value[-half_max:]
return f"{start}...{end}"
@register.filter
def format_duration(seconds):
"""Format seconds into a human-readable duration"""
if not seconds:
return "0s"
minutes, seconds = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
if hours > 0:
return f"{hours}h {minutes}m {seconds}s"
elif minutes > 0:
return f"{minutes}m {seconds}s"
else:
return f"{seconds}s"
@register.simple_tag
def url_replace(request, field, value):
"""Replace a GET parameter in the current URL"""
dict_ = request.GET.copy()
dict_[field] = value
return dict_.urlencode()

View File

View File

@ -0,0 +1,43 @@
# dashboard/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("", views.dashboard_view, name="dashboard"),
path("upload/", views.upload_data_view, name="upload_data"),
path(
"data-source/<int:data_source_id>/",
views.data_source_detail_view,
name="data_source_detail",
),
path(
"chat-session/<str:session_id>/",
views.chat_session_detail_view,
name="chat_session_detail",
),
path("dashboard/create/", views.create_dashboard_view, name="create_dashboard"),
path(
"dashboard/<int:dashboard_id>/edit/",
views.edit_dashboard_view,
name="edit_dashboard",
),
path(
"dashboard/<int:dashboard_id>/delete/",
views.delete_dashboard_view,
name="delete_dashboard",
),
path(
"data-source/<int:data_source_id>/delete/",
views.delete_data_source_view,
name="delete_data_source",
),
path(
"api/dashboard/<int:dashboard_id>/data/",
views.dashboard_data_api,
name="dashboard_data_api",
),
path("search/", views.search_chat_sessions, name="search_chat_sessions"),
path("data-view/", views.data_view, name="data_view"),
]

View File

@ -0,0 +1,161 @@
# dashboard/utils.py
import numpy as np
import pandas as pd
from django.db import models
from django.utils.timezone import make_aware
from .models import ChatSession
def process_csv_file(data_source):
"""
Process the uploaded CSV file and create ChatSession objects
Args:
data_source: DataSource model instance containing the CSV file
"""
try:
# Read the CSV file
file_path = data_source.file.path
df = pd.read_csv(file_path)
# Process each row and create ChatSession objects
for _, row in df.iterrows():
# Handle datetime fields
start_time = None
end_time = None
if "start_time" in row and pd.notna(row["start_time"]):
try:
start_time = make_aware(pd.to_datetime(row["start_time"]))
except Exception:
pass
if "end_time" in row and pd.notna(row["end_time"]):
try:
end_time = make_aware(pd.to_datetime(row["end_time"]))
except Exception:
pass
# Convert boolean fields
escalated = str(row.get("escalated", "")).lower() in [
"true",
"yes",
"1",
"t",
"y",
]
forwarded_hr = str(row.get("forwarded_hr", "")).lower() in [
"true",
"yes",
"1",
"t",
"y",
]
# Create ChatSession object
session = ChatSession(
data_source=data_source,
session_id=str(row.get("session_id", "")),
start_time=start_time,
end_time=end_time,
ip_address=row.get("ip_address") if pd.notna(row.get("ip_address", np.nan)) else None,
country=str(row.get("country", "")),
language=str(row.get("language", "")),
messages_sent=int(row.get("messages_sent", 0)) if pd.notna(row.get("messages_sent", np.nan)) else 0,
sentiment=str(row.get("sentiment", "")),
escalated=escalated,
forwarded_hr=forwarded_hr,
full_transcript=str(row.get("full_transcript", "")),
avg_response_time=float(row.get("avg_response_time", 0))
if pd.notna(row.get("avg_response_time", np.nan))
else None,
tokens=int(row.get("tokens", 0)) if pd.notna(row.get("tokens", np.nan)) else 0,
tokens_eur=float(row.get("tokens_eur", 0)) if pd.notna(row.get("tokens_eur", np.nan)) else None,
category=str(row.get("category", "")),
initial_msg=str(row.get("initial_msg", "")),
user_rating=str(row.get("user_rating", "")),
)
session.save()
return True, f"Successfully processed {len(df)} records."
except Exception as e:
return False, f"Error processing CSV file: {str(e)}"
def generate_dashboard_data(data_sources):
"""
Generate aggregated data for dashboard visualization
Args:
data_sources: QuerySet of DataSource objects
Returns:
dict: Dictionary containing aggregated data for various charts
"""
# Get all chat sessions for the selected data sources
chat_sessions = ChatSession.objects.filter(data_source__in=data_sources)
if not chat_sessions.exists():
return {
"total_sessions": 0,
"avg_response_time": 0,
"total_tokens": 0,
"total_cost": 0,
"sentiment_data": [],
"country_data": [],
"category_data": [],
"time_series_data": [],
}
# Basic statistics
total_sessions = chat_sessions.count()
avg_response_time = (
chat_sessions.filter(avg_response_time__isnull=False).aggregate(avg=models.Avg("avg_response_time"))["avg"] or 0
)
total_tokens = chat_sessions.aggregate(sum=models.Sum("tokens"))["sum"] or 0
total_cost = chat_sessions.filter(tokens_eur__isnull=False).aggregate(sum=models.Sum("tokens_eur"))["sum"] or 0
# Sentiment distribution
sentiment_data = (
chat_sessions.exclude(sentiment="").values("sentiment").annotate(count=models.Count("id")).order_by("-count")
)
# Country distribution
country_data = (
chat_sessions.exclude(country="")
.values("country")
.annotate(count=models.Count("id"))
.order_by("-count")[:10] # Top 10 countries
)
# Category distribution
category_data = (
chat_sessions.exclude(category="").values("category").annotate(count=models.Count("id")).order_by("-count")
)
# Time series data (sessions per day)
time_series_query = (
chat_sessions.filter(start_time__isnull=False)
.annotate(date=models.functions.TruncDate("start_time"))
.values("date")
.annotate(count=models.Count("id"))
.order_by("date")
)
time_series_data = [
{"date": entry["date"].strftime("%Y-%m-%d"), "count": entry["count"]} for entry in time_series_query
]
return {
"total_sessions": total_sessions,
"avg_response_time": round(avg_response_time, 2),
"total_tokens": total_tokens,
"total_cost": round(total_cost, 2),
"sentiment_data": list(sentiment_data),
"country_data": list(country_data),
"category_data": list(category_data),
"time_series_data": time_series_data,
}

View File

@ -0,0 +1,452 @@
# dashboard/views.py
import json
from datetime import timedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Avg, Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from .forms import DashboardForm, DataSourceUploadForm
from .models import ChatSession, Dashboard, DataSource
from .utils import generate_dashboard_data, process_csv_file
@login_required
def dashboard_view(request):
"""Main dashboard view"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return render(request, "dashboard/no_company.html")
# Get the user's dashboards or create a default one
dashboards = Dashboard.objects.filter(company=company)
if not dashboards.exists():
# Create a default dashboard if none exists
data_sources = DataSource.objects.filter(company=company)
if data_sources.exists():
default_dashboard = Dashboard.objects.create(
name="Default Dashboard",
description="Automatically created dashboard",
company=company,
)
default_dashboard.data_sources.set(data_sources)
dashboards = [default_dashboard]
else:
# No data sources available
return redirect("upload_data")
# Use the first dashboard by default or the one specified in the request
selected_dashboard_id = request.GET.get("dashboard_id")
if selected_dashboard_id:
selected_dashboard = get_object_or_404(Dashboard, id=selected_dashboard_id, company=company)
else:
selected_dashboard = dashboards.first()
# Generate dashboard data
dashboard_data = generate_dashboard_data(selected_dashboard.data_sources.all())
# Convert dashboard data to JSON for use in JavaScript
dashboard_data_json = json.dumps(
{
"sentiment_data": dashboard_data["sentiment_data"],
"country_data": dashboard_data["country_data"],
"category_data": dashboard_data["category_data"],
"time_series_data": dashboard_data["time_series_data"],
}
)
context = {
"dashboards": dashboards,
"selected_dashboard": selected_dashboard,
"dashboard_data": dashboard_data,
"dashboard_data_json": dashboard_data_json,
}
return render(request, "dashboard/dashboard.html", context)
@login_required
def upload_data_view(request):
"""View for uploading CSV files"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
if request.method == "POST":
form = DataSourceUploadForm(request.POST, request.FILES, company=company)
if form.is_valid():
data_source = form.save()
# Process the uploaded CSV file
success, message = process_csv_file(data_source)
if success:
messages.success(request, f"File uploaded successfully. {message}")
# Add the new data source to all existing dashboards
dashboards = Dashboard.objects.filter(company=company)
for dashboard in dashboards:
dashboard.data_sources.add(data_source)
return redirect("dashboard")
else:
# If processing failed, delete the data source
data_source.delete()
messages.error(request, message)
else:
messages.error(request, "Form is invalid. Please correct the errors.")
else:
form = DataSourceUploadForm()
# List existing data sources
data_sources = DataSource.objects.filter(company=company).order_by("-uploaded_at")
context = {
"form": form,
"data_sources": data_sources,
}
return render(request, "dashboard/upload.html", context)
@login_required
def data_source_detail_view(request, data_source_id):
"""View for viewing details of a data source"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
data_source = get_object_or_404(DataSource, id=data_source_id, company=company)
# Get all chat sessions for this data source
chat_sessions = ChatSession.objects.filter(data_source=data_source).order_by("-start_time")
# Pagination
paginator = Paginator(chat_sessions, 20) # Show 20 records per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
"data_source": data_source,
"page_obj": page_obj,
}
return render(request, "dashboard/data_source_detail.html", context)
@login_required
def chat_session_detail_view(request, session_id):
"""View for viewing details of a chat session"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
chat_session = get_object_or_404(ChatSession, session_id=session_id, data_source__company=company)
context = {
"session": chat_session,
}
return render(request, "dashboard/chat_session_detail.html", context)
@login_required
def create_dashboard_view(request):
"""View for creating a custom dashboard"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
if request.method == "POST":
form = DashboardForm(request.POST, company=company)
if form.is_valid():
dashboard = form.save()
messages.success(request, f"Dashboard '{dashboard.name}' created successfully.")
return redirect("dashboard")
else:
messages.error(request, "Failed to create dashboard. Please correct the errors.")
else:
form = DashboardForm(company=company)
context = {
"form": form,
"is_create": True,
}
return render(request, "dashboard/dashboard_form.html", context)
@login_required
def edit_dashboard_view(request, dashboard_id):
"""View for editing a dashboard"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
dashboard = get_object_or_404(Dashboard, id=dashboard_id, company=company)
if request.method == "POST":
form = DashboardForm(request.POST, instance=dashboard, company=company)
if form.is_valid():
dashboard = form.save()
messages.success(request, f"Dashboard '{dashboard.name}' updated successfully.")
return redirect("dashboard")
else:
messages.error(request, "Failed to update dashboard. Please correct the errors.")
else:
form = DashboardForm(instance=dashboard, company=company)
context = {
"form": form,
"dashboard": dashboard,
"is_create": False,
}
return render(request, "dashboard/dashboard_form.html", context)
@login_required
def delete_dashboard_view(request, dashboard_id):
"""View for deleting a dashboard"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
dashboard = get_object_or_404(Dashboard, id=dashboard_id, company=company)
if request.method == "POST":
dashboard_name = dashboard.name
dashboard.delete()
messages.success(request, f"Dashboard '{dashboard_name}' deleted successfully.")
return redirect("dashboard")
context = {
"dashboard": dashboard,
}
return render(request, "dashboard/dashboard_confirm_delete.html", context)
@login_required
def delete_data_source_view(request, data_source_id):
"""View for deleting a data source"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
data_source = get_object_or_404(DataSource, id=data_source_id, company=company)
if request.method == "POST":
data_source_name = data_source.name
data_source.delete()
messages.success(request, f"Data source '{data_source_name}' deleted successfully.")
return redirect("upload_data")
context = {
"data_source": data_source,
}
return render(request, "dashboard/data_source_confirm_delete.html", context)
# API views for dashboard data
@login_required
def dashboard_data_api(request, dashboard_id):
"""API endpoint for dashboard data"""
user = request.user
company = user.company
if not company:
return JsonResponse({"error": "User not associated with a company"}, status=403)
dashboard = get_object_or_404(Dashboard, id=dashboard_id, company=company)
dashboard_data = generate_dashboard_data(dashboard.data_sources.all())
return JsonResponse(dashboard_data)
@login_required
def search_chat_sessions(request):
"""View for searching chat sessions"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
query = request.GET.get("q", "")
data_source_id = request.GET.get("data_source_id")
# Base queryset
chat_sessions = ChatSession.objects.filter(data_source__company=company)
# Filter by data source if provided
if data_source_id:
chat_sessions = chat_sessions.filter(data_source_id=data_source_id)
# Apply search query if provided
if query:
chat_sessions = chat_sessions.filter(
Q(session_id__icontains=query)
| Q(country__icontains=query)
| Q(language__icontains=query)
| Q(sentiment__icontains=query)
| Q(category__icontains=query)
| Q(initial_msg__icontains=query)
| Q(full_transcript__icontains=query)
)
# Order by most recent first
chat_sessions = chat_sessions.order_by("-start_time")
# Pagination
paginator = Paginator(chat_sessions, 20) # Show 20 records per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
# Get data source for context if filtered by data source
data_source = None
if data_source_id:
data_source = get_object_or_404(DataSource, id=data_source_id, company=company)
context = {
"query": query,
"page_obj": page_obj,
"data_source": data_source,
}
return render(request, "dashboard/search_results.html", context)
@login_required
def data_view(request):
"""View for viewing all data with filtering options"""
user = request.user
company = user.company
if not company:
messages.warning(
request,
"You are not associated with any company. Please contact an administrator.",
)
return redirect("dashboard")
# Get available data sources
data_sources = DataSource.objects.filter(company=company)
# Get selected data source if any
data_source_id = request.GET.get("data_source_id")
selected_data_source = None
if data_source_id:
selected_data_source = get_object_or_404(DataSource, id=data_source_id, company=company)
# Base queryset
chat_sessions = ChatSession.objects.filter(data_source__company=company)
# Apply data source filter if selected
if selected_data_source:
chat_sessions = chat_sessions.filter(data_source=selected_data_source)
# Apply view filter if any
view = request.GET.get("view", "all")
if view == "recent":
# Sessions from the last 7 days
seven_days_ago = timezone.now() - timedelta(days=7)
chat_sessions = chat_sessions.filter(start_time__gte=seven_days_ago)
elif view == "positive":
# Sessions with positive sentiment
chat_sessions = chat_sessions.filter(Q(sentiment__icontains="positive"))
elif view == "negative":
# Sessions with negative sentiment
chat_sessions = chat_sessions.filter(Q(sentiment__icontains="negative"))
elif view == "escalated":
# Escalated sessions
chat_sessions = chat_sessions.filter(escalated=True)
# Order by most recent first
chat_sessions = chat_sessions.order_by("-start_time")
# Calculate some statistics
total_sessions = chat_sessions.count()
avg_response_time = (
chat_sessions.filter(avg_response_time__isnull=False).aggregate(avg=Avg("avg_response_time"))["avg"] or 0
)
avg_messages = chat_sessions.filter(messages_sent__gt=0).aggregate(avg=Avg("messages_sent"))["avg"] or 0
escalated_count = chat_sessions.filter(escalated=True).count()
escalation_rate = (escalated_count / total_sessions * 100) if total_sessions > 0 else 0
# Pagination
paginator = Paginator(chat_sessions, 20) # Show 20 records per page
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
context = {
"data_sources": data_sources,
"selected_data_source": selected_data_source,
"page_obj": page_obj,
"view": view,
"avg_response_time": avg_response_time,
"avg_messages": avg_messages,
"escalation_rate": escalation_rate,
}
return render(request, "dashboard/data_view.html", context)

View File

@ -0,0 +1 @@
# This file is intentionally left empty to mark the directory as a Python package

View File

@ -0,0 +1,16 @@
"""
ASGI config for dashboard_project project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard_project.settings")
application = get_asgi_application()

View File

@ -0,0 +1,130 @@
# dashboard_project/settings.py
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-your-secret-key-here"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
# Third-party apps
"allauth",
"allauth.account",
"allauth.socialaccount",
"crispy_forms",
"crispy_bootstrap5",
# Custom apps
"dashboard.apps.DashboardConfig",
"accounts.apps.AccountsConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "dashboard_project.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "dashboard_project.wsgi.application"
# Database
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = "static/"
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"),
]
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Media files
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Crispy Forms
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Authentication
AUTH_USER_MODEL = "accounts.CustomUser"
LOGIN_REDIRECT_URL = "dashboard"
LOGOUT_REDIRECT_URL = "login"
ACCOUNT_LOGOUT_ON_GET = True
# django-allauth
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
SITE_ID = 1
ACCOUNT_EMAIL_VERIFICATION = "none"

View File

@ -0,0 +1,17 @@
# dashboard_project/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("accounts.urls")),
path("dashboard/", include("dashboard.urls")),
path("", RedirectView.as_view(url="dashboard/", permanent=False)),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -0,0 +1,16 @@
"""
WSGI config for dashboard_project project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard_project.settings")
application = get_wsgi_application()

View File

@ -0,0 +1,19 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard_project.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError("Couldn't import Django. Are you sure it's installed?") from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,262 @@
/**
* dashboard.css - Styles specific to dashboard functionality
*/
/* Dashboard grid layout */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
/* Slightly larger minmax for widgets */
gap: 1.5rem;
/* Increased gap */
}
/* Dashboard widget cards */
.dashboard-widget {
display: flex;
/* Allow flex for content alignment */
flex-direction: column;
/* Stack header, body, footer vertically */
height: 100%;
/* Ensure widgets fill grid cell height */
}
.dashboard-widget .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.dashboard-widget .card-header .widget-title {
font-size: 1.1rem;
/* Slightly larger widget titles */
font-weight: 600;
}
.dashboard-widget .card-header .widget-actions {
display: flex;
gap: 0.5rem;
}
.dashboard-widget .card-header .widget-actions .btn {
width: 32px;
/* Slightly larger action buttons */
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
background-color: transparent;
border: 1px solid transparent;
color: #6c757d;
}
.dashboard-widget .card-header .widget-actions .btn:hover {
background-color: #f0f0f0;
border-color: #e0e0e0;
color: #333;
}
.dashboard-widget .card-body {
flex-grow: 1;
/* Allow card body to take available space */
padding: 1.25rem;
/* Consistent padding */
}
/* Chart widgets */
.chart-widget .card-body {
display: flex;
flex-direction: column;
}
.chart-widget .chart-container {
flex: 1;
min-height: 250px;
/* Adjusted min-height */
width: 100%;
/* Ensure it takes full width of card body */
}
/* Stat widgets / Stat Cards */
.stat-card {
text-align: center;
padding: 1.5rem;
/* Generous padding */
}
.stat-card .stat-icon {
font-size: 2.25rem;
/* Larger icon */
margin-bottom: 1rem;
display: inline-block;
width: 4.5rem;
height: 4.5rem;
line-height: 4.5rem;
text-align: center;
border-radius: 50%;
background-color: #e9f2ff;
/* Light blue background for icon */
color: #007bff;
/* Primary color for icon */
}
.stat-card .stat-value {
font-size: 2.25rem;
/* Larger stat value */
font-weight: 700;
margin-bottom: 0.25rem;
/* Reduced margin */
line-height: 1.1;
color: #212529;
/* Darker color for value */
}
.stat-card .stat-label {
font-size: 0.9rem;
/* Slightly larger label */
color: #6c757d;
margin-bottom: 0;
}
/* Dashboard theme variations */
.dashboard-theme-light .card {
background-color: #ffffff;
}
.dashboard-theme-dark {
background-color: #212529;
color: #f8f9fa;
}
.dashboard-theme-dark .card {
background-color: #343a40;
color: #f8f9fa;
border-color: #495057;
}
.dashboard-theme-dark .card-header {
background-color: #495057;
border-bottom-color: #6c757d;
}
.dashboard-theme-dark .stat-card .stat-label {
color: #adb5bd;
}
/* Time period selector */
.time-period-selector {
display: flex;
align-items: center;
gap: 0.75rem;
/* Increased gap */
margin-bottom: 1.5rem;
/* Increased margin */
}
.time-period-selector .btn-group {
flex-wrap: wrap;
}
.time-period-selector .btn {
padding: 0.375rem 0.75rem;
/* Bootstrap-like padding */
font-size: 0.875rem;
}
/* Custom metric selector */
.metric-selector {
max-width: 100%;
overflow-x: auto;
white-space: nowrap;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.metric-selector .nav-link {
white-space: nowrap;
padding: 0.5rem 1rem;
font-weight: 500;
}
.metric-selector .nav-link.active {
background-color: #007bff;
color: white;
border-radius: 0.25rem;
}
/* Dashboard loading states */
.widget-placeholder {
min-height: 300px;
background: linear-gradient(90deg, #e9ecef 25%, #f8f9fa 50%, #e9ecef 75%);
/* Lighter gradient */
background-size: 200% 100%;
animation: loading 1.8s infinite ease-in-out;
/* Smoother animation */
border-radius: 0.5rem;
/* Consistent with cards */
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Dashboard empty states */
.empty-state {
padding: 2.5rem;
/* Increased padding */
text-align: center;
color: #6c757d;
background-color: #f8f9fa;
/* Light background for empty state */
border-radius: 0.5rem;
border: 1px dashed #ced4da;
/* Dashed border */
}
.empty-state .empty-state-icon {
font-size: 3.5rem;
/* Larger icon */
margin-bottom: 1.5rem;
opacity: 0.4;
}
.empty-state .empty-state-message {
font-size: 1.2rem;
/* Slightly larger message */
margin-bottom: 1.5rem;
font-weight: 500;
}
.empty-state .btn {
margin-top: 1rem;
}
/* Responsive adjustments */
@media (max-width: 767.98px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.stat-card {
padding: 1rem;
}
.stat-card .stat-icon {
font-size: 1.5rem;
width: 3rem;
height: 3rem;
line-height: 3rem;
}
.stat-card .stat-value {
font-size: 1.5rem;
}
}

View File

@ -0,0 +1,328 @@
/**
* style.css - Global styles for the application
*/
/* General Styles */
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
background-color: #f4f7f9;
/* Lighter, cleaner background */
color: #333;
/* Darker text for better contrast */
line-height: 1.6;
display: flex;
/* Added for sticky footer */
flex-direction: column;
/* Added for sticky footer */
min-height: 100vh;
/* Ensures body takes at least full viewport height */
}
/* Navbar adjustments (if needed, Bootstrap usually handles this well) */
.navbar {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
/* Subtle shadow for depth */
}
/* Helper Classes */
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cursor-pointer {
cursor: pointer;
}
.min-w-150 {
min-width: 150px;
}
/* Card styles */
.card {
border: 1px solid #e0e5e9;
/* Lighter border */
border-radius: 0.5rem;
/* Slightly more rounded corners */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
/* Softer, more modern shadow */
transition:
transform 0.2s ease-in-out,
box-shadow 0.2s ease-in-out;
margin-bottom: 1.5rem;
/* Consistent margin */
}
.card-hover:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
.card-header {
background-color: #ffffff;
/* Clean white header */
border-bottom: 1px solid #e0e5e9;
font-weight: 500;
/* Slightly bolder header text */
padding: 0.75rem 1.25rem;
}
.card-title {
font-size: 1.15rem;
/* Adjusted card title size */
font-weight: 600;
}
/* Sidebar enhancements */
.sidebar {
background-color: #ffffff;
/* White sidebar for a cleaner look */
border-right: 1px solid #e0e5e9;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.03);
transition: all 0.3s;
}
.sidebar-sticky {
padding-top: 1rem;
}
.sidebar .nav-link {
color: #4a5568;
/* Softer link color */
padding: 0.65rem 1.25rem;
/* Adjusted padding */
border-radius: 0.375rem;
/* Bootstrap-like rounded corners for links */
margin: 0.1rem 0.5rem;
/* Margin around links */
font-weight: 500;
}
.sidebar .nav-link:hover {
color: #007bff;
/* Primary color on hover */
background-color: #e9f2ff;
/* Light blue background on hover */
}
.sidebar .nav-link.active {
color: #007bff;
background-color: #d6e4ff;
/* Slightly darker blue for active */
font-weight: 600;
}
.sidebar .nav-link i.me-2 {
width: 20px;
/* Ensure icons align well */
text-align: center;
margin-right: 0.75rem !important;
/* Consistent icon spacing */
}
.sidebar .nav-header {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #718096;
/* Softer header color */
padding: 0.5rem 1.25rem;
margin-top: 1rem;
}
/* Dashboard stats cards */
.stats-card {
border-radius: 0.5rem;
overflow: hidden;
}
.stats-card h3 {
font-size: 1.75rem;
font-weight: 600;
}
.stats-card p {
font-size: 0.875rem;
margin-bottom: 0;
opacity: 0.8;
}
/* Chart containers */
.chart-container {
width: 100%;
height: 300px;
position: relative;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
/* Table enhancements */
.table {
border-color: #e0e5e9;
}
.table th {
font-weight: 600;
/* Bolder table headers */
color: #4a5568;
background-color: #f8f9fc;
/* Light background for headers */
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(0, 0, 0, 0.02);
/* Very subtle striping */
}
.table-hover tbody tr:hover {
background-color: #e9f2ff;
/* Consistent hover with sidebar */
}
/* Form improvements */
.form-control,
.form-select {
border-color: #ced4da;
border-radius: 0.375rem;
/* Consistent border radius */
padding: 0.5rem 0.75rem;
/* Adjusted padding */
}
.form-control:focus,
.form-select:focus {
border-color: #86b7fe;
/* Bootstrap focus color */
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
/* Bootstrap focus shadow */
}
/* Button styling */
.btn {
border-radius: 0.375rem;
/* Consistent border radius */
padding: 0.5rem 1rem;
/* Standard button padding */
font-weight: 500;
transition:
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
}
.btn-primary:hover {
background-color: #0069d9;
border-color: #0062cc;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
}
.btn-secondary:hover {
background-color: #5a6268;
border-color: #545b62;
}
/* Alert styling */
.alert {
border-radius: 0.375rem;
padding: 0.9rem 1.25rem;
}
/* Chat transcript styling */
.chat-transcript {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 0.25rem;
padding: 1rem;
max-height: 500px;
overflow-y: auto;
font-size: 0.875rem;
}
.chat-transcript pre {
white-space: pre-wrap;
font-family: inherit;
margin-bottom: 0;
}
/* Footer styling */
footer {
background-color: #ffffff;
/* White footer */
border-top: 1px solid #e0e5e9;
padding: 1.5rem 0;
color: #6c757d;
font-size: 0.9rem;
margin-top: auto;
/* Added for sticky footer */
}
/* Responsive adjustments */
@media (max-width: 767.98px) {
.main-content {
margin-left: 0;
}
.stats-card h3 {
font-size: 1.5rem;
}
.chart-container {
height: 250px;
}
.card-title {
font-size: 1.25rem;
}
}
/* Print styles */
@media print {
.sidebar,
.navbar,
.btn,
footer {
display: none !important;
}
.main-content {
margin-left: 0 !important;
padding: 0 !important;
}
.card {
break-inside: avoid;
border: none !important;
box-shadow: none !important;
}
.chart-container {
break-inside: avoid;
height: auto !important;
}
}

View File

@ -0,0 +1,253 @@
/**
* dashboard.js - JavaScript for the dashboard functionality
*
* This file handles the interactive features of the dashboard,
* including chart refreshing, dashboard filtering, and dashboard
* customization.
*/
document.addEventListener("DOMContentLoaded", function () {
// Chart responsiveness
function resizeCharts() {
const charts = document.querySelectorAll(".chart-container");
charts.forEach((chart) => {
if (chart.id && window.Plotly) {
Plotly.relayout(chart.id, {
"xaxis.automargin": true,
"yaxis.automargin": true,
});
}
});
}
// Handle window resize
window.addEventListener("resize", function () {
if (window.Plotly) {
resizeCharts();
}
});
// Time range filtering
const timeRangeDropdown = document.getElementById("timeRangeDropdown");
if (timeRangeDropdown) {
const timeRangeLinks = timeRangeDropdown.querySelectorAll(".dropdown-item");
timeRangeLinks.forEach((link) => {
link.addEventListener("click", function (e) {
const url = new URL(this.href);
const dashboardId = url.searchParams.get("dashboard_id");
const timeRange = url.searchParams.get("time_range");
// Fetch updated data via AJAX
if (dashboardId) {
fetchDashboardData(dashboardId, timeRange);
e.preventDefault();
}
});
});
}
// Function to fetch dashboard data
function fetchDashboardData(dashboardId, timeRange) {
const loadingOverlay = document.createElement("div");
loadingOverlay.className = "loading-overlay";
loadingOverlay.innerHTML =
'<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
document.querySelector("main").appendChild(loadingOverlay);
fetch(`/dashboard/api/dashboard/${dashboardId}/data/?time_range=${timeRange || "all"}`)
.then((response) => response.json())
.then((data) => {
updateDashboardStats(data);
updateDashboardCharts(data);
// Update URL without page reload
const url = new URL(window.location.href);
url.searchParams.set("dashboard_id", dashboardId);
if (timeRange) {
url.searchParams.set("time_range", timeRange);
}
window.history.pushState({}, "", url);
document.querySelector(".loading-overlay").remove();
})
.catch((error) => {
console.error("Error fetching dashboard data:", error);
document.querySelector(".loading-overlay").remove();
// Show error message
const alertElement = document.createElement("div");
alertElement.className = "alert alert-danger alert-dismissible fade show";
alertElement.setAttribute("role", "alert");
alertElement.innerHTML = `
Error loading dashboard data. Please try again.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.querySelector("main").prepend(alertElement);
});
}
// Function to update dashboard statistics
function updateDashboardStats(data) {
// Update total sessions
const totalSessionsElement = document.querySelector(".stats-card:nth-child(1) h3");
if (totalSessionsElement) {
totalSessionsElement.textContent = data.total_sessions;
}
// Update average response time
const avgResponseTimeElement = document.querySelector(".stats-card:nth-child(2) h3");
if (avgResponseTimeElement) {
avgResponseTimeElement.textContent = data.avg_response_time + "s";
}
// Update total tokens
const totalTokensElement = document.querySelector(".stats-card:nth-child(3) h3");
if (totalTokensElement) {
totalTokensElement.textContent = data.total_tokens;
}
// Update total cost
const totalCostElement = document.querySelector(".stats-card:nth-child(4) h3");
if (totalCostElement) {
totalCostElement.textContent = "€" + data.total_cost;
}
}
// Function to update dashboard charts
function updateDashboardCharts(data) {
// Update sessions over time chart
const timeSeriesData = data.time_series_data;
if (timeSeriesData && timeSeriesData.length > 0 && window.Plotly) {
const timeSeriesX = timeSeriesData.map((item) => item.date);
const timeSeriesY = timeSeriesData.map((item) => item.count);
Plotly.react(
"sessions-time-chart",
[
{
x: timeSeriesX,
y: timeSeriesY,
type: "scatter",
mode: "lines+markers",
line: {
color: "rgb(75, 192, 192)",
width: 2,
},
marker: {
color: "rgb(75, 192, 192)",
size: 6,
},
},
],
{
margin: { t: 10, r: 10, b: 40, l: 40 },
xaxis: {
title: "Date",
},
yaxis: {
title: "Number of Sessions",
},
}
);
}
// Update sentiment chart
const sentimentData = data.sentiment_data;
if (sentimentData && sentimentData.length > 0 && window.Plotly) {
const sentimentLabels = sentimentData.map((item) => item.sentiment);
const sentimentValues = sentimentData.map((item) => item.count);
const sentimentColors = sentimentLabels.map((sentiment) => {
if (sentiment.toLowerCase().includes("positive")) return "rgb(75, 192, 92)";
if (sentiment.toLowerCase().includes("negative")) return "rgb(255, 99, 132)";
if (sentiment.toLowerCase().includes("neutral")) return "rgb(255, 205, 86)";
return "rgb(201, 203, 207)";
});
Plotly.react(
"sentiment-chart",
[
{
values: sentimentValues,
labels: sentimentLabels,
type: "pie",
marker: {
colors: sentimentColors,
},
hole: 0.4,
textinfo: "label+percent",
insidetextorientation: "radial",
},
],
{
margin: { t: 10, r: 10, b: 10, l: 10 },
}
);
}
// Update country chart
const countryData = data.country_data;
if (countryData && countryData.length > 0 && window.Plotly) {
const countryLabels = countryData.map((item) => item.country);
const countryValues = countryData.map((item) => item.count);
Plotly.react(
"country-chart",
[
{
x: countryValues,
y: countryLabels,
type: "bar",
orientation: "h",
marker: {
color: "rgb(54, 162, 235)",
},
},
],
{
margin: { t: 10, r: 10, b: 40, l: 100 },
xaxis: {
title: "Number of Sessions",
},
}
);
}
// Update category chart
const categoryData = data.category_data;
if (categoryData && categoryData.length > 0 && window.Plotly) {
const categoryLabels = categoryData.map((item) => item.category);
const categoryValues = categoryData.map((item) => item.count);
Plotly.react(
"category-chart",
[
{
labels: categoryLabels,
values: categoryValues,
type: "pie",
textinfo: "label+percent",
insidetextorientation: "radial",
},
],
{
margin: { t: 10, r: 10, b: 10, l: 10 },
}
);
}
}
// Dashboard selector
const dashboardSelector = document.querySelectorAll('a[href^="?dashboard_id="]');
dashboardSelector.forEach((link) => {
link.addEventListener("click", function (e) {
const url = new URL(this.href);
const dashboardId = url.searchParams.get("dashboard_id");
// Fetch updated data via AJAX
if (dashboardId) {
fetchDashboardData(dashboardId);
e.preventDefault();
}
});
});
});

View File

@ -0,0 +1,152 @@
/**
* main.js - Global JavaScript functionality
*
* This file contains general JavaScript functionality used across
* the entire application, including navigation, forms, and UI interactions.
*/
document.addEventListener("DOMContentLoaded", function () {
// Initialize tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Initialize popovers
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
// Toggle sidebar on mobile
const sidebarToggle = document.querySelector("#sidebarToggle");
if (sidebarToggle) {
sidebarToggle.addEventListener("click", function () {
document.querySelector(".sidebar").classList.toggle("show");
});
}
// Auto-dismiss alerts after 5 seconds
setTimeout(function () {
var alerts = document.querySelectorAll(".alert:not(.alert-important)");
alerts.forEach(function (alert) {
if (alert && bootstrap.Alert.getInstance(alert)) {
bootstrap.Alert.getInstance(alert).close();
}
});
}, 5000);
// Form validation
const forms = document.querySelectorAll(".needs-validation");
forms.forEach(function (form) {
form.addEventListener(
"submit",
function (event) {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add("was-validated");
},
false
);
});
// Confirm dialogs
const confirmButtons = document.querySelectorAll("[data-confirm]");
confirmButtons.forEach(function (button) {
button.addEventListener("click", function (event) {
if (!confirm(this.dataset.confirm || "Are you sure?")) {
event.preventDefault();
}
});
});
// Back button
const backButtons = document.querySelectorAll(".btn-back");
backButtons.forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
window.history.back();
});
});
// File input customization
const fileInputs = document.querySelectorAll(".custom-file-input");
fileInputs.forEach(function (input) {
input.addEventListener("change", function (e) {
const fileName = this.files[0]?.name || "Choose file";
const nextSibling = this.nextElementSibling;
if (nextSibling) {
nextSibling.innerText = fileName;
}
});
});
// Search form submit on enter
const searchInputs = document.querySelectorAll(".search-input");
searchInputs.forEach(function (input) {
input.addEventListener("keypress", function (e) {
if (e.key === "Enter") {
e.preventDefault();
this.closest("form").submit();
}
});
});
// Toggle password visibility
const togglePasswordButtons = document.querySelectorAll(".toggle-password");
togglePasswordButtons.forEach(function (button) {
button.addEventListener("click", function () {
const target = document.querySelector(this.dataset.target);
if (target) {
const type = target.getAttribute("type") === "password" ? "text" : "password";
target.setAttribute("type", type);
this.querySelector("i").classList.toggle("fa-eye");
this.querySelector("i").classList.toggle("fa-eye-slash");
}
});
});
// Dropdown menu positioning
const dropdowns = document.querySelectorAll(".dropdown-menu");
dropdowns.forEach(function (dropdown) {
dropdown.addEventListener("click", function (e) {
e.stopPropagation();
});
});
// Responsive table handling
const tables = document.querySelectorAll(".table-responsive");
if (window.innerWidth < 768) {
tables.forEach(function (table) {
table.classList.add("table-responsive-force");
});
}
// Handle special links (printable views, exports)
const printLinks = document.querySelectorAll(".print-link");
printLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
window.print();
});
});
const exportLinks = document.querySelectorAll("[data-export]");
exportLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
// Handle export functionality if needed
console.log("Export requested:", this.dataset.export);
});
});
// Handle sidebar collapse on small screens
function handleSidebarOnResize() {
if (window.innerWidth < 768) {
document.querySelector(".sidebar")?.classList.remove("show");
}
}
window.addEventListener("resize", handleSidebarOnResize);
});

View File

@ -0,0 +1,29 @@
<!-- templates/accounts/login.html -->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Login | Chat Analytics{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-4">
<div class="card-header">
<h4 class="card-title mb-0">Login</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Don't have an account? <a href="{% url 'register' %}">Register</a></p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
<!-- templates/accounts/password_change.html -->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Change Password | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Change Password</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'profile' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Profile
</a>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Change Your Password</h5>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Change Password</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,41 @@
<!-- templates/accounts/password_change_done.html -->
{% extends 'base.html' %}
{% block title %}Password Changed | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Password Changed</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'profile' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Profile
</a>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">Password Changed Successfully</h5>
</div>
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-check-circle fa-4x text-success mb-3"></i>
<h4>Your password has been changed successfully!</h4>
<p>Your new password is now active. You can use it the next time you log in.</p>
</div>
<div class="mt-4">
<a href="{% url 'profile' %}" class="btn btn-primary">Return to Profile</a>
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary ms-2"
>Go to Dashboard</a
>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,189 @@
<!-- templates/accounts/profile.html -->
{% extends 'base.html' %}
{% block title %}My Profile | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">My Profile</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Account Information</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4 fw-bold">Username:</div>
<div class="col-md-8">{{ user.username }}</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Email:</div>
<div class="col-md-8">{{ user.email }}</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Company:</div>
<div class="col-md-8">
{% if user.company %}
{{ user.company.name }}
{% else %}
<span class="text-muted">Not assigned to a company</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Role:</div>
<div class="col-md-8">
{% if user.is_staff %}
<span class="badge bg-danger">Admin</span>
{% elif user.is_company_admin %}
<span class="badge bg-primary">Company Admin</span>
{% else %}
<span class="badge bg-secondary">User</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Last Login:</div>
<div class="col-md-8">{{ user.last_login|date:"F d, Y H:i" }}</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Date Joined:</div>
<div class="col-md-8">{{ user.date_joined|date:"F d, Y H:i" }}</div>
</div>
</div>
<div class="card-footer">
<a href="{% url 'password_change' %}" class="btn btn-primary">Change Password</a>
</div>
</div>
</div>
{% if user.company %}
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Company Information</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-4 fw-bold">Company Name:</div>
<div class="col-md-8">{{ user.company.name }}</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Description:</div>
<div class="col-md-8">
{{ user.company.description|default:"No description available." }}
</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Created:</div>
<div class="col-md-8">{{ user.company.created_at|date:"F d, Y" }}</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Total Employees:</div>
<div class="col-md-8">{{ user.company.employees.count }}</div>
</div>
<div class="row mb-3">
<div class="col-md-4 fw-bold">Data Sources:</div>
<div class="col-md-8">{{ user.company.data_sources.count }}</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% if user.is_company_admin or user.is_staff %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Admin Actions</h5>
</div>
<div class="card-body">
<div class="row">
{% if user.is_staff %}
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Manage Users</h5>
<p class="card-text">Manage users and assign them to companies.</p>
<a
href="{% url 'admin:accounts_customuser_changelist' %}"
class="btn btn-primary"
>Manage Users</a
>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Manage Companies</h5>
<p class="card-text">Create and edit companies in the system.</p>
<a
href="{% url 'admin:accounts_company_changelist' %}"
class="btn btn-primary"
>Manage Companies</a
>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Admin Dashboard</h5>
<p class="card-text">Go to the full admin dashboard.</p>
<a href="{% url 'admin:index' %}" class="btn btn-primary">Admin Dashboard</a>
</div>
</div>
</div>
{% elif user.is_company_admin %}
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Manage Dashboards</h5>
<p class="card-text">Create and edit dashboards for your company.</p>
<a href="{% url 'create_dashboard' %}" class="btn btn-primary"
>Manage Dashboards</a
>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Upload Data</h5>
<p class="card-text">Upload and manage data sources for analysis.</p>
<a href="{% url 'upload_data' %}" class="btn btn-primary">Upload Data</a>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<h5 class="card-title">Search Sessions</h5>
<p class="card-text">Search and analyze chat sessions.</p>
<a href="{% url 'search_chat_sessions' %}" class="btn btn-primary"
>Search Sessions</a
>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,29 @@
<!-- templates/accounts/register.html -->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Register | Chat Analytics{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card mt-4">
<div class="card-header">
<h4 class="card-title mb-0">Register</h4>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Register</button>
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Already have an account? <a href="{% url 'login' %}">Login</a></p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,312 @@
<!-- templates/base.html -->
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Chat Analytics Dashboard{% endblock %}</title>
<!-- Bootstrap CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<!-- Font Awesome -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@latest/css/all.min.css"
/>
<!-- Plotly.js -->
<script
src="https://cdn.jsdelivr.net/npm/plotly.js@latest/dist/plotly.min.js"
charset="utf-8"
></script>
<!-- Custom CSS -->
<link rel="stylesheet" href="{% static 'css/style.css' %}" />
<link rel="stylesheet" href="{% static 'css/dashboard.css' %}" />
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-md navbar-dark bg-dark absolute-top">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'dashboard' %}">Chat Analytics</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarCollapse"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarCollapse">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}"
href="{% url 'dashboard' %}"
>Dashboard</a
>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'upload_data' %}active{% endif %}"
href="{% url 'upload_data' %}"
>Upload Data</a
>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'search_chat_sessions' %}active{% endif %}"
href="{% url 'search_chat_sessions' %}"
>Search</a
>
</li>
</ul>
<div class="d-flex">
{% if user.is_authenticated %}
<div class="dropdown">
<button
class="btn btn-outline-light dropdown-toggle"
type="button"
id="userDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{% if user.company %}
<span class="badge bg-info me-1">{{ user.company.name }}</span>
{% endif %}
{{ user.username }}
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li><a class="dropdown-item" href="{% url 'profile' %}">Profile</a></li>
{% if user.is_staff %}
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
<li>
<hr class="dropdown-divider" />
</li>
<li><a class="dropdown-item" href="{% url 'logout' %}">Logout</a></li>
</ul>
</div>
{% else %}
<a href="{% url 'login' %}" class="btn btn-outline-light me-2">Login</a>
<a href="{% url 'register' %}" class="btn btn-light">Register</a>
{% endif %}
</div>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav
id="sidebarMenu"
class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse sticky-top h-100 p-0"
>
<div class="sidebar-sticky pt-3">
{% block sidebar %}
<ul class="nav flex-column">
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}"
href="{% url 'dashboard' %}"
>
<i class="fas fa-tachometer-alt me-2"></i>
Dashboard
</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'upload_data' %}active{% endif %}"
href="{% url 'upload_data' %}"
>
<i class="fas fa-upload me-2"></i>
Upload Data
</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'search_chat_sessions' %}active{% endif %}"
href="{% url 'search_chat_sessions' %}"
>
<i class="fas fa-search me-2"></i>
Search
</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if request.resolver_match.url_name == 'data_view' %}active{% endif %}"
href="{% url 'data_view' %}"
>
<i class="fas fa-table me-2"></i>
Data View
</a>
</li>
{% if user.is_authenticated and user.company %}
{% if dashboards %}
<li class="nav-header"><strong>Dashboards</strong></li>
{% for dashboard in dashboards %}
<li class="nav-item">
<a
class="nav-link {% if selected_dashboard.id == dashboard.id %}active{% endif %}"
href="{% url 'dashboard' %}?dashboard_id={{ dashboard.id }}"
>
<i class="fas fa-chart-line me-2"></i>
{{ dashboard.name }}
</a>
</li>
{% endfor %}
<li class="nav-item">
<a class="nav-link" href="{% url 'create_dashboard' %}">
<i class="fas fa-plus-circle me-2"></i>
New Dashboard
</a>
</li>
{% endif %}
{% if data_sources %}
<li class="nav-header"><strong>Data Sources</strong></li>
{% for data_source in data_sources %}
<li class="nav-item">
<a class="nav-link" href="{% url 'data_source_detail' data_source.id %}">
<i class="fas fa-database me-2"></i>
{{ data_source.name }}
</a>
</li>
{% endfor %}
{% endif %}
{% endif %}
</ul>
{% endblock %}
</div>
</nav>
<!-- Main content -->
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
{# {% if messages %} #}
{# <div class="messages mt-3"> #}
{# {% for message in messages %} #}
{# <div class="alert {% if message.tags == 'error' %}alert-danger{% elif message.tags == 'success' %}alert-success{% elif message.tags == 'warning' %}alert-warning{% else %}alert-info{% endif %} alert-dismissible fade show" role="alert"> #}
{# {{ message }} #}
{# <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> #}
{# </div> #}
{# {% endfor %} #}
{# </div> #}
{# {% endif %} #}
{% block content %}
{% endblock %}
</main>
</div>
</div>
<footer>
<div class="container">
<p>&copy; {% now "Y" %} Chat Analytics Dashboard. All rights reserved.</p>
</div>
</footer>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery (for Ajax) -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
{% block extra_js %}
{{ block.super }}
{% if messages %}
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1100;">
<!-- Toasts will be appended here -->
</div>
{% for message in messages %}
<!-- Pre-render message data that will be used by JavaScript -->
<script type="application/json" id="message-data-{{ forloop.counter }}">
{
"message": "{{ message|escapejs }}",
"tags": "{{ message.tags|default:'' }}"
}
</script>
{% endfor %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const toastContainer = document.querySelector(".toast-container");
if (!toastContainer) return;
// Find all message data elements
const messageDataElements = document.querySelectorAll('script[id^="message-data-"]');
messageDataElements.forEach(function (dataElement) {
try {
const messageData = JSON.parse(dataElement.textContent);
createToast(messageData.message, messageData.tags);
} catch (e) {
console.error("Error parsing message data:", e);
}
});
function createToast(messageText, messageTags) {
let toastClass = "";
let autohide = true;
let delay = 5000;
if (messageTags.includes("debug")) {
toastClass = "bg-secondary text-white";
} else if (messageTags.includes("info")) {
toastClass = "bg-info text-dark";
} else if (messageTags.includes("success")) {
toastClass = "bg-success text-white";
} else if (messageTags.includes("warning")) {
toastClass = "bg-warning text-dark";
autohide = false;
} else if (messageTags.includes("error")) {
toastClass = "bg-danger text-white";
autohide = false;
} else {
toastClass = "bg-light text-dark";
}
const toastId =
"toast-" + Date.now() + "-" + Math.random().toString(36).substring(2, 11);
const toastHtml = `
<div id="${toastId}" class="toast ${toastClass}" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto">Notification</strong>
<small class="${toastClass.includes("text-white") ? "" : "text-muted"} me-2">Just now</small>
<button type="button" class="btn-close ${toastClass.includes("text-white") ? "btn-close-white" : ""}" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
${messageText}
</div>
</div>`;
toastContainer.insertAdjacentHTML("beforeend", toastHtml);
const toastElement = document.getElementById(toastId);
if (toastElement) {
const toast = new bootstrap.Toast(toastElement, {
autohide: autohide,
delay: autohide ? delay : undefined,
});
toastElement.addEventListener("hidden.bs.toast", function () {
toastElement.remove();
});
toast.show();
}
}
});
</script>
{% endif %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,130 @@
{% extends 'base.html' %}
{% block title %}Chat Session {{ session.session_id }} | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Chat Session: {{ session.session_id }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a
href="{% url 'data_source_detail' session.data_source.id %}"
class="btn btn-sm btn-outline-secondary"
>
<i class="fas fa-arrow-left"></i> Back to Data Source
</a>
</div>
</div>
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Session Information</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Session ID:</strong> {{ session.session_id }}</p>
<p><strong>Start Time:</strong> {{ session.start_time|date:"F d, Y H:i" }}</p>
<p><strong>End Time:</strong> {{ session.end_time|date:"F d, Y H:i" }}</p>
<p><strong>IP Address:</strong> {{ session.ip_address|default:"N/A" }}</p>
<p><strong>Country:</strong> {{ session.country|default:"N/A" }}</p>
<p><strong>Language:</strong> {{ session.language|default:"N/A" }}</p>
</div>
<div class="col-md-6">
<p><strong>Messages Sent:</strong> {{ session.messages_sent }}</p>
<p>
<strong>Average Response Time:</strong>
{{ session.avg_response_time|floatformat:2 }}s
</p>
<p><strong>Tokens:</strong> {{ session.tokens }}</p>
<p><strong>Token Cost:</strong> €{{ session.tokens_eur|floatformat:2 }}</p>
<p><strong>Category:</strong> {{ session.category|default:"N/A" }}</p>
<p>
<strong>Sentiment:</strong>
{% if session.sentiment %}
{% if 'positive' in session.sentiment|lower %}
<span class="badge bg-success">{{ session.sentiment }}</span>
{% elif 'negative' in session.sentiment|lower %}
<span class="badge bg-danger">{{ session.sentiment }}</span>
{% elif 'neutral' in session.sentiment|lower %}
<span class="badge bg-warning">{{ session.sentiment }}</span>
{% else %}
<span class="badge bg-secondary">{{ session.sentiment }}</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</p>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<p class="mb-2"><strong>Initial Message:</strong></p>
<div class="card bg-light">
<div class="card-body">
<p class="mb-0">{{ session.initial_msg|default:"N/A" }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Additional Info</h5>
</div>
<div class="card-body">
<p>
<strong>Escalated:</strong> {% if session.escalated %}
<span class="badge bg-danger">Yes</span>
{% else %}
<span class="badge bg-success">No</span>
{% endif %}
</p>
<p>
<strong>Forwarded to HR:</strong> {% if session.forwarded_hr %}
<span class="badge bg-danger">Yes</span>
{% else %}
<span class="badge bg-success">No</span>
{% endif %}
</p>
<p><strong>User Rating:</strong> {{ session.user_rating|default:"N/A" }}</p>
<hr />
<p>
<strong>Data Source:</strong>
<a href="{% url 'data_source_detail' session.data_source.id %}"
>{{ session.data_source.name }}</a
>
</p>
<p><strong>Company:</strong> {{ session.data_source.company.name }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Full Transcript</h5>
</div>
<div class="card-body">
{% if session.full_transcript %}
<div class="chat-transcript" style="max-height: 500px; overflow-y: auto;">
<pre style="white-space: pre-wrap; font-family: inherit;">
{{ session.full_transcript }}</pre
>
</div>
{% else %}
<p class="text-center text-muted">No transcript available.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,286 @@
<!-- templates/dashboard/dashboard.html -->
{% extends 'base.html' %}
{% load static %}
{% block title %}Dashboard | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">{{ selected_dashboard.name }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a
href="{% url 'edit_dashboard' selected_dashboard.id %}"
class="btn btn-sm btn-outline-secondary"
>
<i class="fas fa-edit"></i> Edit
</a>
<a
href="{% url 'delete_dashboard' selected_dashboard.id %}"
class="btn btn-sm btn-outline-danger"
>
<i class="fas fa-trash"></i> Delete
</a>
</div>
<div class="dropdown">
<button
class="btn btn-sm btn-outline-primary dropdown-toggle"
type="button"
id="timeRangeDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="fas fa-calendar"></i> Time Range
</button>
<ul class="dropdown-menu" aria-labelledby="timeRangeDropdown">
<li>
<a class="dropdown-item" href="?dashboard_id={{ selected_dashboard.id }}&time_range=7"
>Last 7 days</a
>
</li>
<li>
<a class="dropdown-item" href="?dashboard_id={{ selected_dashboard.id }}&time_range=30"
>Last 30 days</a
>
</li>
<li>
<a class="dropdown-item" href="?dashboard_id={{ selected_dashboard.id }}&time_range=90"
>Last 90 days</a
>
</li>
<li>
<a class="dropdown-item" href="?dashboard_id={{ selected_dashboard.id }}&time_range=all"
>All time</a
>
</li>
</ul>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-3">
<div class="card stats-card bg-primary text-white">
<div class="card-body">
<h6 class="card-title">Total Sessions</h6>
<h3>{{ dashboard_data.total_sessions }}</h3>
<p>Chat conversations</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card bg-success text-white">
<div class="card-body">
<h6 class="card-title">Avg Response Time</h6>
<h3>{{ dashboard_data.avg_response_time }}s</h3>
<p>Average response</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card bg-info text-white">
<div class="card-body">
<h6 class="card-title">Total Tokens</h6>
<h3>{{ dashboard_data.total_tokens }}</h3>
<p>Total usage</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card bg-warning text-white">
<div class="card-body">
<h6 class="card-title">Total Cost</h6>
<h3>€{{ dashboard_data.total_cost }}</h3>
<p>Token cost</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Sessions Over Time</h5>
</div>
<div class="card-body">
<div id="sessions-time-chart" class="chart-container"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Sentiment Analysis</h5>
</div>
<div class="card-body">
<div id="sentiment-chart" class="chart-container"></div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Top Countries</h5>
</div>
<div class="card-body">
<div id="country-chart" class="chart-container"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Categories</h5>
</div>
<div class="card-body">
<div id="category-chart" class="chart-container"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener("DOMContentLoaded", function () {
// Parse the dashboard data from JSON
const dashboardData = JSON.parse("{{ dashboard_data_json|safe }}");
// Sessions over time chart
const timeSeriesData = dashboardData.time_series_data;
const timeSeriesX = timeSeriesData.map((item) => item.date);
const timeSeriesY = timeSeriesData.map((item) => item.count);
Plotly.newPlot(
"sessions-time-chart",
[
{
x: timeSeriesX,
y: timeSeriesY,
type: "scatter",
mode: "lines+markers",
line: {
color: "rgb(75, 192, 192)",
width: 2,
},
marker: {
color: "rgb(75, 192, 192)",
size: 6,
},
},
],
{
margin: { t: 10, r: 10, b: 40, l: 40 },
xaxis: {
title: "Date",
},
yaxis: {
title: "Number of Sessions",
},
}
);
// Sentiment analysis chart
const sentimentData = dashboardData.sentiment_data;
if (sentimentData.length > 0) {
const sentimentLabels = sentimentData.map((item) => item.sentiment);
const sentimentValues = sentimentData.map((item) => item.count);
const sentimentColors = sentimentLabels.map((sentiment) => {
if (sentiment.toLowerCase().includes("positive")) return "rgb(75, 192, 92)";
if (sentiment.toLowerCase().includes("negative")) return "rgb(255, 99, 132)";
if (sentiment.toLowerCase().includes("neutral")) return "rgb(255, 205, 86)";
return "rgb(201, 203, 207)";
});
Plotly.newPlot(
"sentiment-chart",
[
{
values: sentimentValues,
labels: sentimentLabels,
type: "pie",
marker: {
colors: sentimentColors,
},
hole: 0.4,
textinfo: "label+percent",
insidetextorientation: "radial",
},
],
{
margin: { t: 10, r: 10, b: 10, l: 10 },
}
);
} else {
document.getElementById("sentiment-chart").innerHTML =
'<div class="text-center py-5"><p class="text-muted">No sentiment data available</p></div>';
}
// Country chart
const countryData = dashboardData.country_data;
if (countryData.length > 0) {
const countryLabels = countryData.map((item) => item.country);
const countryValues = countryData.map((item) => item.count);
Plotly.newPlot(
"country-chart",
[
{
x: countryValues,
y: countryLabels,
type: "bar",
orientation: "h",
marker: {
color: "rgb(54, 162, 235)",
},
},
],
{
margin: { t: 10, r: 10, b: 40, l: 100 },
xaxis: {
title: "Number of Sessions",
},
}
);
} else {
document.getElementById("country-chart").innerHTML =
'<div class="text-center py-5"><p class="text-muted">No country data available</p></div>';
}
// Category chart
const categoryData = dashboardData.category_data;
if (categoryData.length > 0) {
const categoryLabels = categoryData.map((item) => item.category);
const categoryValues = categoryData.map((item) => item.count);
Plotly.newPlot(
"category-chart",
[
{
labels: categoryLabels,
values: categoryValues,
type: "pie",
textinfo: "label+percent",
insidetextorientation: "radial",
},
],
{
margin: { t: 10, r: 10, b: 10, l: 10 },
}
);
} else {
document.getElementById("category-chart").innerHTML =
'<div class="text-center py-5"><p class="text-muted">No category data available</p></div>';
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% block title %}Delete Dashboard | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Delete Dashboard</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="card-title mb-0">Confirm Deletion</h5>
</div>
<div class="card-body">
<p class="lead">
Are you sure you want to delete the dashboard "<strong>{{ dashboard.name }}</strong>"?
</p>
<p>
This action cannot be undone. The dashboard will be permanently deleted, but the
underlying data sources will remain intact.
</p>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'dashboard' %}" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-danger">Delete Dashboard</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,43 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}
{% if is_create %}Create Dashboard{% else %}Edit Dashboard{% endif %}
| Chat Analytics
{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">{% if is_create %}Create Dashboard{% else %}Edit Dashboard{% endif %}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
{% if is_create %}Create Dashboard{% else %}Edit Dashboard{% endif %}
</h5>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">
{% if is_create %}Create Dashboard{% else %}Update Dashboard{% endif %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,50 @@
<!-- templates/dashboard/data_source_confirm_delete.html -->
{% extends 'base.html' %}
{% block title %}Delete Data Source | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Delete Data Source</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a
href="{% url 'data_source_detail' data_source.id %}"
class="btn btn-sm btn-outline-secondary"
>
<i class="fas fa-arrow-left"></i> Back to Data Source
</a>
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h5 class="card-title mb-0">Confirm Deletion</h5>
</div>
<div class="card-body">
<p class="lead">
Are you sure you want to delete the data source
"<strong>{{ data_source.name }}</strong>"?
</p>
<p>
This action cannot be undone. The data source and all associated chat sessions
({{ data_source.chat_sessions.count }} sessions) will be permanently deleted.
</p>
<form method="post">
{% csrf_token %}
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'data_source_detail' data_source.id %}" class="btn btn-secondary"
>Cancel</a
>
<button type="submit" class="btn btn-danger">Delete Data Source</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,216 @@
<!-- templates/dashboard/data_source_detail.html -->
{% extends 'base.html' %}
{% load dashboard_extras %}
{% block title %}{{ data_source.name }} | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">{{ data_source.name }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'upload_data' %}" class="btn btn-sm btn-outline-secondary me-2">
<i class="fas fa-arrow-left"></i> Back to Data Sources
</a>
<a href="{% url 'delete_data_source' data_source.id %}" class="btn btn-sm btn-outline-danger">
<i class="fas fa-trash"></i> Delete
</a>
</div>
</div>
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Data Source Details</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Name:</strong> {{ data_source.name }}</p>
<p><strong>Uploaded At:</strong> {{ data_source.uploaded_at|date:"F d, Y H:i" }}</p>
<p><strong>File:</strong> {{ data_source.file.name|split:"/"|last }}</p>
</div>
<div class="col-md-6">
<p><strong>Company:</strong> {{ data_source.company.name }}</p>
<p><strong>Total Sessions:</strong> {{ page_obj.paginator.count }}</p>
<p><strong>Description:</strong> {{ data_source.description }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Filter Sessions</h5>
</div>
<div class="card-body">
<form method="get" action="{% url 'search_chat_sessions' %}">
<div class="input-group mb-3">
<input
type="text"
name="q"
class="form-control"
placeholder="Search sessions..."
aria-label="Search sessions"
/>
<input type="hidden" name="data_source_id" value="{{ data_source.id }}" />
<button class="btn btn-outline-primary" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Chat Sessions ({{ page_obj.paginator.count }})</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>Start Time</th>
<th>Country</th>
<th>Language</th>
<th>Sentiment</th>
<th>Messages</th>
<th>Tokens</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in page_obj %}
<tr>
<td>{{ session.session_id|truncatechars:10 }}</td>
<td>{{ session.start_time|date:"M d, Y H:i" }}</td>
<td>{{ session.country }}</td>
<td>{{ session.language }}</td>
<td>
{% if session.sentiment %}
{% if 'positive' in session.sentiment|lower %}
<span class="badge bg-success">{{ session.sentiment }}</span>
{% elif 'negative' in session.sentiment|lower %}
<span class="badge bg-danger">{{ session.sentiment }}</span>
{% elif 'neutral' in session.sentiment|lower %}
<span class="badge bg-warning">{{ session.sentiment }}</span>
{% else %}
<span class="badge bg-secondary">{{ session.sentiment }}</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>{{ session.messages_sent }}</td>
<td>{{ session.tokens }}</td>
<td>{{ session.category|default:"N/A" }}</td>
<td>
<a
href="{% url 'chat_session_detail' session.session_id %}"
class="btn btn-sm btn-outline-primary"
>
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center">No chat sessions found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a
class="page-link"
href="?page={{ page_obj.previous_page_number }}"
aria-label="Previous"
>
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a
class="page-link"
href="?page={{ page_obj.next_page_number }}"
aria-label="Next"
>
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a
class="page-link"
href="?page={{ page_obj.paginator.num_pages }}"
aria-label="Last"
>
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,316 @@
<!-- templates/dashboard/data_view.html -->
{% extends 'base.html' %}
{% load dashboard_extras %}
{% block title %}Data View | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Data View</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
{% if selected_data_source %}
<a
href="{% url 'data_source_detail' selected_data_source.id %}"
class="btn btn-sm btn-outline-secondary"
>
<i class="fas fa-database"></i> View Source
</a>
{% endif %}
</div>
<div class="dropdown">
<button
class="btn btn-sm btn-outline-primary dropdown-toggle"
type="button"
id="dataViewDropdown"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i class="fas fa-filter"></i> Filter
</button>
<ul class="dropdown-menu" aria-labelledby="dataViewDropdown">
<li><a class="dropdown-item" href="?view=all">All Sessions</a></li>
<li><a class="dropdown-item" href="?view=recent">Recent Sessions</a></li>
<li><a class="dropdown-item" href="?view=positive">Positive Sentiment</a></li>
<li><a class="dropdown-item" href="?view=negative">Negative Sentiment</a></li>
<li><a class="dropdown-item" href="?view=escalated">Escalated Sessions</a></li>
</ul>
</div>
</div>
</div>
<!-- Data Source Selection -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Data Source Selection</h5>
</div>
<div class="card-body">
<form method="get" class="row g-3 align-items-center">
<div class="col-md-6">
<select name="data_source_id" class="form-select" aria-label="Select Data Source">
<option value="">All Data Sources</option>
{% for ds in data_sources %}
<option
value="{{ ds.id }}"
{% if selected_data_source.id == ds.id %}selected{% endif %}
>
{{ ds.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<select name="view" class="form-select" aria-label="Select View">
<option value="all" {% if view == 'all' %}selected{% endif %}>All Sessions</option>
<option value="recent" {% if view == 'recent' %}selected{% endif %}>
Recent Sessions
</option>
<option value="positive" {% if view == 'positive' %}selected{% endif %}>
Positive Sentiment
</option>
<option value="negative" {% if view == 'negative' %}selected{% endif %}>
Negative Sentiment
</option>
<option value="escalated" {% if view == 'escalated' %}selected{% endif %}>
Escalated Sessions
</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Apply</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Data Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
Chat Sessions
{% if selected_data_source %}
for {{ selected_data_source.name }}
{% endif %}
{% if view != 'all' %}
({{ view|title }})
{% endif %}
</h5>
<span class="badge bg-primary">{{ page_obj.paginator.count }} sessions</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>Start Time</th>
<th>Country</th>
<th>Language</th>
<th>Messages</th>
<th>Sentiment</th>
<th>Response Time</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in page_obj %}
<tr>
<td>{{ session.session_id|truncatechars:10 }}</td>
<td>{{ session.start_time|date:"M d, Y H:i" }}</td>
<td>{{ session.country|default:"N/A" }}</td>
<td>{{ session.language|default:"N/A" }}</td>
<td>{{ session.messages_sent }}</td>
<td>
{% if session.sentiment %}
{% if 'positive' in session.sentiment|lower %}
<span class="badge bg-success">{{ session.sentiment }}</span>
{% elif 'negative' in session.sentiment|lower %}
<span class="badge bg-danger">{{ session.sentiment }}</span>
{% elif 'neutral' in session.sentiment|lower %}
<span class="badge bg-warning">{{ session.sentiment }}</span>
{% else %}
<span class="badge bg-secondary">{{ session.sentiment }}</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>{{ session.avg_response_time|floatformat:2 }}s</td>
<td>{{ session.category|default:"N/A" }}</td>
<td>
<a
href="{% url 'chat_session_detail' session.session_id %}"
class="btn btn-sm btn-outline-primary"
>
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center">No chat sessions found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a
class="page-link"
href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page=1"
aria-label="First"
>
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a
class="page-link"
href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.previous_page_number }}"
aria-label="Previous"
>
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<a
class="page-link"
href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ num }}"
>{{ num }}</a
>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a
class="page-link"
href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ num }}"
>{{ num }}</a
>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a
class="page-link"
href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.next_page_number }}"
aria-label="Next"
>
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a
class="page-link"
href="?{% if selected_data_source %}data_source_id={{ selected_data_source.id }}&{% endif %}{% if view %}view={{ view }}&{% endif %}page={{ page_obj.paginator.num_pages }}"
aria-label="Last"
>
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Data Summary -->
{% if page_obj %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="card stats-card bg-light">
<div class="card-body">
<h6 class="card-title">Total Sessions</h6>
<h3>{{ page_obj.paginator.count }}</h3>
<p>Chat conversations</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card bg-light">
<div class="card-body">
<h6 class="card-title">Avg Response Time</h6>
<h3>{{ avg_response_time|floatformat:2 }}s</h3>
<p>Average response</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card bg-light">
<div class="card-body">
<h6 class="card-title">Avg Messages</h6>
<h3>{{ avg_messages|floatformat:1 }}</h3>
<p>Per conversation</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stats-card bg-light">
<div class="card-body">
<h6 class="card-title">Escalation Rate</h6>
<h3>{{ escalation_rate|floatformat:1 }}%</h3>
<p>Escalated sessions</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,40 @@
<!-- templates/dashboard/no_company.html -->
{% extends 'base.html' %}
{% block title %}No Company | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">No Company Association</h1>
</div>
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-warning text-dark">
<h5 class="card-title mb-0">Account Not Associated with a Company</h5>
</div>
<div class="card-body text-center">
<div class="mb-4">
<i class="fas fa-building fa-4x text-warning mb-3"></i>
<h4>You are not currently associated with any company</h4>
<p class="lead">You need to be associated with a company to access the dashboard.</p>
</div>
<p>
Please contact an administrator to have your account assigned to a company. Once your
account is associated with a company, you'll be able to access the dashboard and its
features.
</p>
<div class="mt-4">
<a href="{% url 'profile' %}" class="btn btn-primary">View Your Profile</a>
<a href="{% url 'logout' %}" class="btn btn-outline-secondary ms-2">Logout</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,222 @@
<!-- templates/dashboard/search_results.html -->
{% extends 'base.html' %}
{% block title %}Search Results | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Search Results</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Search Chat Sessions</h5>
</div>
<div class="card-body">
<form method="get" action="{% url 'search_chat_sessions' %}">
<div class="input-group">
<input
type="text"
name="q"
class="form-control"
placeholder="Search sessions..."
value="{{ query }}"
aria-label="Search sessions"
/>
{% if data_source %}
<input type="hidden" name="data_source_id" value="{{ data_source.id }}" />
{% endif %}
<button class="btn btn-outline-primary" type="submit">
<i class="fas fa-search"></i> Search
</button>
</div>
<div class="mt-2 text-muted">
<small
>Search by session ID, country, language, sentiment, category, or message
content.</small
>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
Results {% if query %}for "{{ query }}"{% endif %}
{% if data_source %}in {{ data_source.name }}{% endif %}
({{ page_obj.paginator.count }})
</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Session ID</th>
<th>Start Time</th>
<th>Data Source</th>
<th>Country</th>
<th>Language</th>
<th>Sentiment</th>
<th>Messages</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for session in page_obj %}
<tr>
<td>{{ session.session_id|truncatechars:10 }}</td>
<td>{{ session.start_time|date:"M d, Y H:i" }}</td>
<td>
<a href="{% url 'data_source_detail' session.data_source.id %}"
>{{ session.data_source.name|truncatechars:15 }}</a
>
</td>
<td>{{ session.country }}</td>
<td>{{ session.language }}</td>
<td>
{% if session.sentiment %}
{% if 'positive' in session.sentiment|lower %}
<span class="badge bg-success">{{ session.sentiment }}</span>
{% elif 'negative' in session.sentiment|lower %}
<span class="badge bg-danger">{{ session.sentiment }}</span>
{% elif 'neutral' in session.sentiment|lower %}
<span class="badge bg-warning">{{ session.sentiment }}</span>
{% else %}
<span class="badge bg-secondary">{{ session.sentiment }}</span>
{% endif %}
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>{{ session.messages_sent }}</td>
<td>{{ session.category|default:"N/A" }}</td>
<td>
<a
href="{% url 'chat_session_detail' session.session_id %}"
class="btn btn-sm btn-outline-primary"
>
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center">
No chat sessions found matching your criteria.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav aria-label="Page navigation" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a
class="page-link"
href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page=1"
aria-label="First"
>
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item">
<a
class="page-link"
href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.previous_page_number }}"
aria-label="Previous"
>
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="First">
<span aria-hidden="true">&laquo;&laquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active">
<a
class="page-link"
href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ num }}"
>{{ num }}</a
>
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a
class="page-link"
href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ num }}"
>{{ num }}</a
>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a
class="page-link"
href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.next_page_number }}"
aria-label="Next"
>
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item">
<a
class="page-link"
href="?{% if query %}q={{ query }}&{% endif %}{% if data_source %}data_source_id={{ data_source.id }}&{% endif %}page={{ page_obj.paginator.num_pages }}"
aria-label="Last"
>
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
<li class="page-item disabled">
<a class="page-link" href="#" aria-label="Last">
<span aria-hidden="true">&raquo;&raquo;</span>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,197 @@
<!-- templates/dashboard/upload.html -->
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% load dashboard_extras %}
{% block title %}Upload Data | Chat Analytics{% endblock %}
{% block content %}
<div
class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"
>
<h1 class="h2">Upload Data</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{% url 'dashboard' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Upload CSV File</h5>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
{% csrf_token %} {{ form|crispy }}
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">CSV File Format</h5>
</div>
<div class="card-body">
<p>The CSV file should contain the following columns:</p>
<table class="table table-sm">
<thead>
<tr>
<th>Column</th>
<th>Description</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr>
<td>session_id</td>
<td>Unique identifier for the chat session</td>
<td>String</td>
</tr>
<tr>
<td>start_time</td>
<td>When the session started</td>
<td>Datetime</td>
</tr>
<tr>
<td>end_time</td>
<td>When the session ended</td>
<td>Datetime</td>
</tr>
<tr>
<td>ip_address</td>
<td>IP address of the user</td>
<td>String</td>
</tr>
<tr>
<td>country</td>
<td>Country of the user</td>
<td>String</td>
</tr>
<tr>
<td>language</td>
<td>Language used in the conversation</td>
<td>String</td>
</tr>
<tr>
<td>messages_sent</td>
<td>Number of messages in the conversation</td>
<td>Integer</td>
</tr>
<tr>
<td>sentiment</td>
<td>Sentiment analysis of the conversation</td>
<td>String</td>
</tr>
<tr>
<td>escalated</td>
<td>Whether the conversation was escalated</td>
<td>Boolean</td>
</tr>
<tr>
<td>forwarded_hr</td>
<td>Whether the conversation was forwarded to HR</td>
<td>Boolean</td>
</tr>
<tr>
<td>full_transcript</td>
<td>Full transcript of the conversation</td>
<td>Text</td>
</tr>
<tr>
<td>avg_response_time</td>
<td>Average response time in seconds</td>
<td>Float</td>
</tr>
<tr>
<td>tokens</td>
<td>Total number of tokens used</td>
<td>Integer</td>
</tr>
<tr>
<td>tokens_eur</td>
<td>Cost of tokens in EUR</td>
<td>Float</td>
</tr>
<tr>
<td>category</td>
<td>Category of the conversation</td>
<td>String</td>
</tr>
<tr>
<td>initial_msg</td>
<td>First message from the user</td>
<td>Text</td>
</tr>
<tr>
<td>user_rating</td>
<td>User rating of the conversation</td>
<td>String</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% if data_sources %}
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Uploaded Data Sources</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Uploaded</th>
<th>File</th>
<th>Sessions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for data_source in data_sources %}
<tr>
<td>{{ data_source.name }}</td>
<td>{{ data_source.description|truncatechars:50 }}</td>
<td>{{ data_source.uploaded_at|date:"M d, Y H:i" }}</td>
<td>{{ data_source.file.name|split:"/"|last }}</td>
<td>{{ data_source.chat_sessions.count }}</td>
<td>
<a
href="{% url 'data_source_detail' data_source.id %}"
class="btn btn-sm btn-outline-primary"
>
<i class="fas fa-eye"></i>
</a>
<a
href="{% url 'delete_data_source' data_source.id %}"
class="btn btn-sm btn-outline-danger"
>
<i class="fas fa-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

16
dashboard_project/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for dashboard_project project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dashboard_project.settings")
application = get_wsgi_application()

45
docker-compose.yml Normal file
View File

@ -0,0 +1,45 @@
# docker-compose.yml
version: '3.8'
services:
web:
build: .
command: gunicorn dashboard_project.wsgi:application --bind 0.0.0.0:8000
volumes:
- .:/app
- static_volume:/app/staticfiles
- media_volume:/app/media
ports:
- '8000:8000'
environment:
- DEBUG=0
- SECRET_KEY=your_secret_key_here
- ALLOWED_HOSTS=localhost,127.0.0.1
depends_on:
- db
db:
image: postgres:13
volumes:
- postgres_data:/var/lib/postgresql/data/
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=dashboard_db
nginx:
image: nginx:latest
ports:
- '80:80'
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- static_volume:/app/staticfiles
- media_volume:/app/media
depends_on:
- web
volumes:
postgres_data:
static_volume:
media_volume:

25
nginx/conf.d/default.conf Normal file
View File

@ -0,0 +1,25 @@
# nginx/conf.d/default.conf
upstream dashboard {
server web:8000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://dashboard;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}
location /static/ {
alias /app/staticfiles/;
}
location /media/ {
alias /app/media/;
}
}

39
package-lock.json generated Normal file
View File

@ -0,0 +1,39 @@
{
"name": "LiveGraphsDjango",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"prettier": "^3.5.3",
"prettier-plugin-jinja-template": "^2.1.0"
}
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-jinja-template": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.1.0.tgz",
"integrity": "sha512-mzoCp2Oy9BDSug80fw3B3J4n4KQj1hRvoQOL1akqcDKBb5nvYxrik9zUEDs4AEJ6nK7QDTGoH0y9rx7AlnQ78Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0"
}
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"devDependencies": {
"prettier": "^3.5.3",
"prettier-plugin-jinja-template": "^2.1.0"
}
}

67
pyproject.toml Normal file
View File

@ -0,0 +1,67 @@
[project]
name = "livegraphsdjango"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"crispy-bootstrap5>=2025.4",
"django>=5.2.1",
"django-allauth>=65.8.0",
"django-crispy-forms>=2.4",
"gunicorn>=23.0.0",
"numpy>=2.2.5",
"pandas>=2.2.3",
"plotly>=6.1.0",
"python-dotenv>=1.1.0",
"whitenoise>=6.9.0",
]
[tool.ruff]
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
# Same as Black.
line-length = 120
indent-width = 4
# Assume Python 3.13
target-version = "py313"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]
fixable = ["ALL"]
unfixable = []
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"

256
uv.lock generated Normal file
View File

@ -0,0 +1,256 @@
version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "asgiref"
version = "3.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
]
[[package]]
name = "crispy-bootstrap5"
version = "2025.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-crispy-forms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/65/d4406d4c914aedea8e2de9cf44d70bd1f6e339c87ef2d5791ff10fa223d2/crispy_bootstrap5-2025.4.tar.gz", hash = "sha256:d675ea7e245048905077dfe16bf1fa1ee16842f52fe88164ccc8a5e2d11119b3", size = 23913, upload-time = "2025-04-02T12:33:16.019Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/9a/4f1166cc82c9f777cf9a5bc2a75171d63301ac317c5de8f59bd44bfe2b7a/crispy_bootstrap5-2025.4-py3-none-any.whl", hash = "sha256:51efa19c7d40e339774a6fe23407e83b95b7634cad6de70fd1f1093131bea1d9", size = 24772, upload-time = "2025-04-02T12:33:14.904Z" },
]
[[package]]
name = "django"
version = "5.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/10/0d546258772b8f31398e67c85e52c66ebc2b13a647193c3eef8ee433f1a8/django-5.2.1.tar.gz", hash = "sha256:57fe1f1b59462caed092c80b3dd324fd92161b620d59a9ba9181c34746c97284", size = 10818735, upload-time = "2025-05-07T14:06:17.543Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/92/7448697b5838b3a1c6e1d2d6a673e908d0398e84dc4f803a2ce11e7ffc0f/django-5.2.1-py3-none-any.whl", hash = "sha256:a9b680e84f9a0e71da83e399f1e922e1ab37b2173ced046b541c72e1589a5961", size = 8301833, upload-time = "2025-05-07T14:06:10.955Z" },
]
[[package]]
name = "django-allauth"
version = "65.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/50/4fa3a907be1a49f5ad3b7cd67944d4b91186ef6743fe0fd401c160ba6341/django_allauth-65.8.0.tar.gz", hash = "sha256:9da589d99d412740629333a01865a90c95c97e0fae0cde789aa45a8fda90e83b", size = 1679978, upload-time = "2025-05-08T19:31:27.975Z" }
[[package]]
name = "django-crispy-forms"
version = "2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/a1/ffd7b0e160296121d88e3e173165370000ee4de7328f5c4f4b266638dcd9/django_crispy_forms-2.4.tar.gz", hash = "sha256:915e1ffdeb2987d78b33fabfeff8e5203c8776aa910a3a659a2c514ca125f3bd", size = 278932, upload-time = "2025-04-13T07:25:00.176Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/ec/a25f81e56a674e63cf6c3dd8e36b1b3fecc238fecd6098504adc0cc61402/django_crispy_forms-2.4-py3-none-any.whl", hash = "sha256:5a4b99876cfb1bdd3e47727731b6d4197c51c0da502befbfbec6a93010b02030", size = 31446, upload-time = "2025-04-13T07:24:58.516Z" },
]
[[package]]
name = "gunicorn"
version = "23.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
]
[[package]]
name = "livegraphsdjango"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "crispy-bootstrap5" },
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-crispy-forms" },
{ name = "gunicorn" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "plotly" },
{ name = "python-dotenv" },
{ name = "whitenoise" },
]
[package.metadata]
requires-dist = [
{ name = "crispy-bootstrap5", specifier = ">=2025.4" },
{ name = "django", specifier = ">=5.2.1" },
{ name = "django-allauth", specifier = ">=65.8.0" },
{ name = "django-crispy-forms", specifier = ">=2.4" },
{ name = "gunicorn", specifier = ">=23.0.0" },
{ name = "numpy", specifier = ">=2.2.5" },
{ name = "pandas", specifier = ">=2.2.3" },
{ name = "plotly", specifier = ">=6.1.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "whitenoise", specifier = ">=6.9.0" },
]
[[package]]
name = "narwhals"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/e6/66682dd5ffe656358e07194fa9551b1d41e33952831a40553e791dd98dfc/narwhals-1.39.1.tar.gz", hash = "sha256:cf15389e6f8c5321e8cd0ca8b5bace3b1aea5f5622fa59dfd64821998741d836", size = 484444, upload-time = "2025-05-15T17:45:10.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/c4/b83520ecc27840a4d58bd585ae0ec0f8a4f2b0c5a965b66749254a54de0e/narwhals-1.39.1-py3-none-any.whl", hash = "sha256:68d0f29c760f1a9419ada537f35f21ff202b0be1419e6d22135a0352c6d96deb", size = 355009, upload-time = "2025-05-15T17:45:07.954Z" },
]
[[package]]
name = "numpy"
version = "2.2.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920, upload-time = "2025-04-19T23:27:42.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102, upload-time = "2025-04-19T22:41:16.234Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709, upload-time = "2025-04-19T22:41:38.472Z" },
{ url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173, upload-time = "2025-04-19T22:41:47.823Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502, upload-time = "2025-04-19T22:41:58.689Z" },
{ url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417, upload-time = "2025-04-19T22:42:19.897Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807, upload-time = "2025-04-19T22:42:44.433Z" },
{ url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611, upload-time = "2025-04-19T22:43:09.928Z" },
{ url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747, upload-time = "2025-04-19T22:43:36.983Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594, upload-time = "2025-04-19T22:47:10.523Z" },
{ url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356, upload-time = "2025-04-19T22:47:30.253Z" },
{ url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778, upload-time = "2025-04-19T22:44:09.251Z" },
{ url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279, upload-time = "2025-04-19T22:44:31.383Z" },
{ url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247, upload-time = "2025-04-19T22:44:40.361Z" },
{ url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087, upload-time = "2025-04-19T22:44:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964, upload-time = "2025-04-19T22:45:12.451Z" },
{ url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214, upload-time = "2025-04-19T22:45:37.734Z" },
{ url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788, upload-time = "2025-04-19T22:46:01.908Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672, upload-time = "2025-04-19T22:46:28.585Z" },
{ url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102, upload-time = "2025-04-19T22:46:39.949Z" },
{ url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096, upload-time = "2025-04-19T22:47:00.147Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pandas"
version = "2.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643, upload-time = "2024-09-20T13:09:25.522Z" },
{ url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573, upload-time = "2024-09-20T13:09:28.012Z" },
{ url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085, upload-time = "2024-09-20T19:02:10.451Z" },
{ url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809, upload-time = "2024-09-20T13:09:30.814Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316, upload-time = "2024-09-20T19:02:13.825Z" },
{ url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055, upload-time = "2024-09-20T13:09:33.462Z" },
{ url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175, upload-time = "2024-09-20T13:09:35.871Z" },
{ url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650, upload-time = "2024-09-20T13:09:38.685Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177, upload-time = "2024-09-20T13:09:41.141Z" },
{ url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526, upload-time = "2024-09-20T19:02:16.905Z" },
{ url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013, upload-time = "2024-09-20T13:09:44.39Z" },
{ url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620, upload-time = "2024-09-20T19:02:20.639Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" },
]
[[package]]
name = "plotly"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/e3/66eabba0b35095027e1ae5cb2e091cd168d44362242b5496baac9a460697/plotly-6.1.0.tar.gz", hash = "sha256:f13f497ccc2d97f06f771a30b27fab0cbd220f2975865f4ecbc75057135521de", size = 7545417, upload-time = "2025-05-15T16:04:39.532Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/11/83ae52318353f9da4a88cc23e7f9dbc3d449b3f0fd6158fba15eb3c3b816/plotly-6.1.0-py3-none-any.whl", hash = "sha256:a29d3ed523c9d7960095693af1ee52689830df0f9c6bae3e5e92c20c4f5684c3", size = 16118476, upload-time = "2025-05-15T16:04:30.81Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "whitenoise"
version = "6.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/cf/c15c2f21aee6b22a9f6fc9be3f7e477e2442ec22848273db7f4eb73d6162/whitenoise-6.9.0.tar.gz", hash = "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609", size = 25920, upload-time = "2025-02-06T22:16:34.957Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b2/2ce9263149fbde9701d352bda24ea1362c154e196d2fda2201f18fc585d7/whitenoise-6.9.0-py3-none-any.whl", hash = "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df", size = 20161, upload-time = "2025-02-06T22:16:32.589Z" },
]