Hard reset
This commit is contained in:
parent
5dee11b68b
commit
ba62b44426
124 changed files with 0 additions and 18120 deletions
|
|
@ -1,36 +0,0 @@
|
|||
*.sqlite3
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.log
|
||||
*.pot
|
||||
*.mo
|
||||
*.swp
|
||||
.DS_Store
|
||||
.env
|
||||
.vscode/
|
||||
.idea/
|
||||
.git/
|
||||
.github/
|
||||
.gitignore
|
||||
.editorconfig
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
.nx/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv/
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
celerybeat-schedule
|
||||
*.md
|
||||
*.bat
|
||||
notebooks/
|
||||
documents/
|
||||
models/
|
||||
eslint.config.mjs
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
# Editor configuration, see http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
56
.gitignore
vendored
56
.gitignore
vendored
|
|
@ -1,56 +0,0 @@
|
|||
# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# compiled output
|
||||
dist
|
||||
build
|
||||
tmp
|
||||
out-tsc
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
.nx/cache
|
||||
.nx/workspace-data
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
.env
|
||||
static
|
||||
.github
|
||||
__pycache__/
|
||||
|
||||
*.sqlite3
|
||||
*.local.bat
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
stages:
|
||||
- test
|
||||
- build
|
||||
|
||||
run_tests:
|
||||
stage: test
|
||||
image: python:3.12
|
||||
variables:
|
||||
DJANGO_SECRET_KEY: 'random_secret_key_for_ci'
|
||||
before_script:
|
||||
- python -m pip install --upgrade pip
|
||||
- pip install --no-cache-dir -r requirements/base.txt
|
||||
script:
|
||||
- python manage.py test --verbosity=2
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
build_and_push:
|
||||
stage: build
|
||||
image: docker:24.0.7
|
||||
variables:
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ''
|
||||
services:
|
||||
- name: docker:24.0.7-dind
|
||||
alias: docker
|
||||
command: ['--tls=false', '--host=tcp://0.0.0.0:2375']
|
||||
script:
|
||||
- echo "Waiting for Docker daemon..."
|
||||
- for i in $(seq 1 30); do docker info && break || sleep 1; done
|
||||
- echo "Logging in to registry ${REGISTRY_URL}"
|
||||
- echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USERNAME" --password-stdin "$REGISTRY_URL"
|
||||
- export IMAGE_NAME="${REGISTRY_URL}/${IMAGE_PATH}:${IMAGE_TAG}"
|
||||
- echo "Building image ${IMAGE_NAME}"
|
||||
- docker build -t "$IMAGE_NAME" -f ./compose/prod/python/Dockerfile --no-cache .
|
||||
- echo "Pushing image ${IMAGE_NAME}"
|
||||
- docker push "$IMAGE_NAME"
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG
|
||||
when: always
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
# Add files here to ignore them from prettier formatting
|
||||
/dist
|
||||
/build
|
||||
/coverage
|
||||
/.nx/cache
|
||||
/.nx/workspace-data
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"singleQuote": true
|
||||
}
|
||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"nrwl.angular-console",
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
23
.vscode/launch.json
vendored
23
.vscode/launch.json
vendored
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug api with Nx",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["nx", "serve", "api"],
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect=9229"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"sourceMaps": true,
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/api/dist/**/*.(m|c|)js",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
34
.vscode/settings.json
vendored
34
.vscode/settings.json
vendored
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": false,
|
||||
"editor.detectIndentation": false,
|
||||
|
||||
"files.trimTrailingWhitespace": true,
|
||||
|
||||
"files.eol": "\n",
|
||||
|
||||
"editor.formatOnSave": true,
|
||||
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue"
|
||||
],
|
||||
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": false
|
||||
},
|
||||
|
||||
"[typescript]": {
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": false
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": false
|
||||
}
|
||||
}
|
||||
331
ARCHITECTURE.md
331
ARCHITECTURE.md
|
|
@ -1,331 +0,0 @@
|
|||
# Agent System Architecture Diagram
|
||||
|
||||
## System Overview (current stack)
|
||||
|
||||
Docker Compose (dev) services on one network:
|
||||
|
||||
- `web` (Vite dev) :5173
|
||||
- `api` (Django + Channels) :8000
|
||||
- `celery` worker shares Django code
|
||||
- `fyp-redis` broker/channel :6379
|
||||
- `mcp-agent-server` MCP runtime :8001 (HTTP)
|
||||
|
||||
MCP wiring:
|
||||
|
||||
- `MCP_AGENT_URL=http://mcp-agent-server:8001` (required)
|
||||
- MCP server runs in HTTP mode, exposes `/execute` and `/health` endpoints
|
||||
- All agent execution delegates to the remote MCP server (no local LLM fallback)
|
||||
|
||||
Flow: Frontend → API (HTTP), Frontend ↔ AgentConsumer (WS), API queues Celery, Celery calls MCP server over HTTP, events return via Redis → Channels → WS.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND (Vue 3 + TypeScript) │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Agents.vue │ │ AgentDetail.vue │ │ agentStore.ts │ │
|
||||
│ ├─────────────────┤ ├──────────────────────┤ ├─────────────────────┤ │
|
||||
│ │ • List agents │ │ • Run execution │ │ • WebSocket connect │ │
|
||||
│ │ • Fetch from API│ │ • JSON input │ │ • Event handling │ │
|
||||
│ │ • Show status │ │ • Live log display │ │ • State management │ │
|
||||
│ └────────┬────────┘ │ • Stop button │ │ • Auto-reconnect │ │
|
||||
│ │ │ • Status indicator │ │ • Type-safe API │ │
|
||||
│ │ └────────┬─────────────┘ └─────────────────────┘ │
|
||||
│ │ │ │
|
||||
└───────────┼────────────────────┼─────────────────────────────────────────┘
|
||||
│ │
|
||||
│ │ WebSocket
|
||||
│ HTTP/REST │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND (Django + Channels) │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────┐ ┌─────────────────┐ │
|
||||
│ │ AgentViewSet │ │ AgentConsumer │ │ Middleware │ │
|
||||
│ ├─────────────────┤ ├──────────────────────────┤ ├─────────────────┤ │
|
||||
│ │ REST API: │ │ WebSocket Handler: │ │ • Auth check │ │
|
||||
│ │ • GET /agent/ │ │ • connect() │ │ • User validate │ │
|
||||
│ │ • POST /agent/ │ │ • receive() │ │ • Group mgmt │ │
|
||||
│ │ • GET /agent/id │ │ • handle_start_agent() │ └─────────────────┘ │
|
||||
│ │ │ │ • handle_stop_agent() │ │
|
||||
│ │ Returns: Agent │ │ • agent_event() │ ┌─────────────────┐ │
|
||||
│ │ metadata │ │ • agent_completed() │ │ Serializers │ │
|
||||
│ └────────┬────────┘ │ • agent_error() │ ├─────────────────┤ │
|
||||
│ │ └────────┬─────────────────┘ │ • AgentSerializer │
|
||||
│ │ │ │ • ExecutionSer. │ │
|
||||
│ │ │ │ • EventSerializer │
|
||||
│ │ │ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Django Database (SQLite/PostgreSQL) │ │
|
||||
│ ├─────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ • Agent (uuid, name, description, status, user) │ │
|
||||
│ │ • AgentExecution (uuid, input_data, output_data, status) │ │
|
||||
│ │ • AgentEvent (uuid, event_type, content, timestamp) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────┬───────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Celery Task Queue
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ CELERY WORKER PROCESS │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ start_agent_task_mcp() [MCP-only execution] │ │
|
||||
│ ├──────────────────────────────────────────────────────────────────┤ │
|
||||
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 1. Initialize & Output "started" event │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 2. Call MCPAgentClient.execute_agent() │ │ │
|
||||
│ │ │ └─ POST to {MCP_AGENT_URL}/execute │ │ │
|
||||
│ │ │ with: agent_id, query, input_data │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 3. Await response from MCP server │ │ │
|
||||
│ │ │ (handles all RAG, LLM, context retrieval) │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 4. Forward any events from MCP to WebSocket │ │ │
|
||||
│ │ │ └─ Progress, message, step events displayed live │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ 5. Save result & Output "completed" event │ │ │
|
||||
│ │ │ └─ Send via Channel Layer to WebSocket Group │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└──────────────────────┬───────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Channel Layer Broadcast
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ REDIS (Message Broker & Cache) │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ • Celery task queue │
|
||||
│ • Channel layer for WebSocket group communication │
|
||||
│ • Session cache (optional) │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
MCP Runtime (HTTP mode)
|
||||
|
||||
- Service: `mcp-agent-server` (container `dynavera-mcp-agent`)
|
||||
- Listens on `0.0.0.0:8001` with `/execute` and `/health` HTTP endpoints
|
||||
- Handles all agent execution: RAG retrieval, LLM inference, context management
|
||||
- Shares code and `build/rag_db` read-only from host
|
||||
- Completely separate from Django/Celery; communicates only via HTTP
|
||||
|
||||
## Execution Flow Sequence
|
||||
|
||||
```
|
||||
Frontend Backend LLM System
|
||||
│ │ │
|
||||
├─ User Input ──────────────>│ │
|
||||
│ (JSON via WebSocket) │ │
|
||||
│ │ │
|
||||
│ ├─ Create Execution │
|
||||
│ │ Record │
|
||||
│ │ │
|
||||
│ ├─ Queue Celery Task │
|
||||
│ │ │
|
||||
│<── execution_started ──────┤ │
|
||||
│ (WebSocket message) │ │
|
||||
│ │ │
|
||||
│ ├─ Output "initializing" │
|
||||
│<── agent_event ────────────┤ │
|
||||
│ (WebSocket) │ │
|
||||
│ │ │
|
||||
│ ├─ Load GPT4All Model │
|
||||
│ │ │
|
||||
│ │─────────────────────────>│ Load Model
|
||||
│ │ (~10-30 seconds) │
|
||||
│ │<───────────────────────── Model Ready
|
||||
│ │ │
|
||||
│ ├─ Output "retrieving" │
|
||||
│<── agent_event ────────────┤ │
|
||||
│ (WebSocket) │ │
|
||||
│ │ │
|
||||
│ ├─ Query RAG DB (if exists) │
|
||||
│ │ (ChromaDB) │
|
||||
│ │ │
|
||||
│ ├─ Output "generating" │
|
||||
│<── agent_event ────────────┤ │
|
||||
│ (WebSocket) │ │
|
||||
│ │ │
|
||||
│ │───────────────────────────>│ Generate
|
||||
│ │ generate(prompt, │ Response
|
||||
│ │ max_tokens=200) │
|
||||
│ │<───────────────────────── Response
|
||||
│ │ (5-30 seconds) │
|
||||
│ │ │
|
||||
│<── execution_completed ────┤ │
|
||||
│ (WebSocket with output) │ │
|
||||
│ │ │
|
||||
└ Display Log & Result │ │
|
||||
```
|
||||
|
||||
## Data Flow: Input to Output
|
||||
|
||||
```
|
||||
User Enters JSON
|
||||
↓
|
||||
{"query": "What is fNIRS?"}
|
||||
↓
|
||||
Frontend sends via WebSocket
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ AgentConsumer.receive(text_data) │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
├─ Parse JSON
|
||||
├─ Validate action
|
||||
└─ Route to handler
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ handle_start_agent() │
|
||||
├─────────────────────────────────────┤
|
||||
│ • Get agent from DB │
|
||||
│ • Create AgentExecution │
|
||||
│ • Queue Celery task │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
├─ Send execution_started event
|
||||
│ (back to WebSocket)
|
||||
│
|
||||
├─ Queue in Celery/Redis
|
||||
│
|
||||
┌──────────────┴──────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
Celery Worker Database Updated
|
||||
│ │
|
||||
├─ Fetch execution │
|
||||
├─ Initialize models │
|
||||
├─ Send progress events │
|
||||
│ │
|
||||
├─ Query RAG (if available) │
|
||||
│ ├─ Load embedder │
|
||||
│ ├─ Connect to ChromaDB │
|
||||
│ └─ Query for context │
|
||||
│ │
|
||||
├─ Initialize LLM │
|
||||
│ ├─ Load GPT4All model │
|
||||
│ └─ Prepare prompt │
|
||||
│ │
|
||||
├─ Generate response │
|
||||
│ └─ model.generate() │
|
||||
│ │
|
||||
├─ Create result dict │
|
||||
│ │
|
||||
├─ Save to AgentExecution │
|
||||
│ ├─ output_data │
|
||||
│ ├─ status = 'completed' │
|
||||
│ └─ completed_at │
|
||||
│ │
|
||||
└─ Send via Channel Layer
|
||||
to WebSocket Group
|
||||
│
|
||||
├─ event_type: agent_event
|
||||
├─ event_type: agent_completed
|
||||
│
|
||||
▼
|
||||
Frontend receives
|
||||
│
|
||||
├─ Update agentStore
|
||||
├─ Push to eventLog
|
||||
├─ Display in UI
|
||||
│
|
||||
▼
|
||||
User sees result
|
||||
```
|
||||
|
||||
## Component Interaction
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND STATE MANAGEMENT │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ agentStore (Pinia) │
|
||||
│ ├─ socket: WebSocket connection │
|
||||
│ ├─ isConnected: boolean │
|
||||
│ ├─ agentId: UUID │
|
||||
│ ├─ currentExecutionId: UUID │
|
||||
│ ├─ executionStatus: 'idle'|'running'|'completed' │
|
||||
│ ├─ events: Array<AgentEvent> │
|
||||
│ │ │
|
||||
│ ├─ connect(agentId) │
|
||||
│ ├─ startAgent(inputData) │
|
||||
│ ├─ stopAgent() │
|
||||
│ ├─ disconnect() │
|
||||
│ └─ handleMessage(data) │
|
||||
│ │
|
||||
│ AgentDetail.vue (Uses Store) │
|
||||
│ ├─ Subscribes to: │
|
||||
│ │ ├─ agentStore.isConnected │
|
||||
│ │ ├─ agentStore.executionStatus │
|
||||
│ │ └─ agentStore.eventLog │
|
||||
│ │ │
|
||||
│ └─ Calls: │
|
||||
│ ├─ agentStore.connect() on mount │
|
||||
│ ├─ agentStore.startAgent() on button click │
|
||||
│ ├─ agentStore.disconnect() on unmount │
|
||||
│ └─ agentStore.stopAgent() on stop button │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Message Type Mapping
|
||||
|
||||
```
|
||||
WebSocket Message Type → Handler Function → Event Display
|
||||
|
||||
"execution_started" → handleMessage → "Started" tag + message
|
||||
"agent_event" → handleMessage → Event type specific
|
||||
├─ "progress" → Display stage → [PROGRESS] stage: message
|
||||
├─ "message" → Display text → [MESSAGE] content
|
||||
└─ "step" → Display step → [STEP] content
|
||||
|
||||
"execution_completed" → handleMessage → "Completed" tag + output
|
||||
"execution_error" → handleMessage → "Error" tag + message
|
||||
"execution_stopped" → handleMessage → "Stopped" tag + message
|
||||
"error" → handleMessage → "Error" tag + message
|
||||
"connection" → handleMessage → Console log
|
||||
```
|
||||
|
||||
## Database Schema Relationships
|
||||
|
||||
```
|
||||
User (from auth)
|
||||
│
|
||||
├─────── (1:N) ─────────> Agent
|
||||
│ ├─ uuid (PK)
|
||||
│ ├─ name
|
||||
│ ├─ description
|
||||
│ ├─ status
|
||||
│ ├─ created_at
|
||||
│ └─ updated_at
|
||||
│ │
|
||||
│ ├─────── (1:N) ──────────> AgentExecution
|
||||
│ ├─ uuid (PK)
|
||||
│ ├─ status
|
||||
│ ├─ input_data (JSON)
|
||||
│ ├─ output_data (JSON)
|
||||
│ ├─ error_message
|
||||
│ ├─ created_at
|
||||
│ ├─ started_at
|
||||
│ ├─ completed_at
|
||||
│ │ │
|
||||
│ │ ├─ (1:N) ──> AgentEvent
|
||||
│ │ ├─ uuid (PK)
|
||||
│ │ ├─ event_type
|
||||
│ │ ├─ content (JSON)
|
||||
│ │ └─ timestamp
|
||||
│ │
|
||||
│ └─ user_id (FK)
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
59
README.md
59
README.md
|
|
@ -1,59 +0,0 @@
|
|||
# An Agentic Approach to Domain-Specific Trainers - Dynavera
|
||||
|
||||
A proof-of-concept platform for **automating the induction and support of new hires or team members** into a role or domain using **AI agents**. This project demonstrates a reusable workflow that combines a modern full-stack application with AI-driven guidance and assessment.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Project Goals](#project-goals)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Features](#features)
|
||||
- [Usage](#usage)
|
||||
|
||||
---
|
||||
|
||||
## Project Goals
|
||||
|
||||
The main objectives of this project are:
|
||||
|
||||
1. **Reusable Workflow** – Create a pipeline that can automatically onboard and guide new hires or team members in a specific domain.
|
||||
2. **AI Agent Integration** – Use intelligent agents to provide guidance, monitor progress, and adapt learning to individual users.
|
||||
3. **Real-World Testing** – Evaluate the suitability and effectiveness of the tool in realistic onboarding scenarios.
|
||||
4. **Domain Specific Trainers** – Support the creation of trainers specialized for different roles, fields, or industries.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** [Django](https://www.djangoproject.com/)
|
||||
- **Frontend:** [Vue 3](https://vuejs.org/) + [Vite](https://vitejs.dev/)
|
||||
- **AI Agents:** Python-based agents (TBD)
|
||||
- **Containerization:** Docker + Docker Compose
|
||||
- **Database:** (TBD)
|
||||
- **Authentication:** JWT / OAuth2 / Custom Managed (TBD)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for a detailed system overview, component interaction, execution flow, and data flow diagrams.
|
||||
|
||||
## Features
|
||||
|
||||
- Automated onboarding workflow for new hires.
|
||||
- Role/domain-specific AI training modules.
|
||||
- Adaptive guidance and personalized learning paths.
|
||||
- Dashboard for tracking user progress and feedback.
|
||||
- Modular AI agent integration (Python/JS).
|
||||
- Extensible to multiple domains and roles.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Navigate to the frontend URL (hosted at `https://project.viswamedha.com`).
|
||||
2. Register a new user or login.
|
||||
3. Select the role/domain to train in.
|
||||
4. Follow the guided AI-assisted onboarding workflow.
|
||||
5. Track progress and view recommendations on the dashboard.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from apps.agents.models import Agent, AgentExecution, AgentEvent
|
||||
|
||||
|
||||
@admin.register(Agent)
|
||||
class AgentAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user', 'status', 'created_at')
|
||||
list_filter = ('status', 'created_at')
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
|
||||
@admin.register(AgentExecution)
|
||||
class AgentExecutionAdmin(admin.ModelAdmin):
|
||||
list_display = ('agent', 'user', 'status', 'created_at')
|
||||
list_filter = ('status', 'created_at')
|
||||
search_fields = ('agent__name',)
|
||||
|
||||
|
||||
@admin.register(AgentEvent)
|
||||
class AgentEventAdmin(admin.ModelAdmin):
|
||||
list_display = ('event_type', 'execution', 'timestamp')
|
||||
list_filter = ('event_type', 'timestamp')
|
||||
search_fields = ('execution__agent__name',)
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AgentsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.agents'
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from apps.agents.models import Agent, AgentExecution, AgentEvent
|
||||
from apps.agents.tasks import start_agent_task_mcp
|
||||
|
||||
|
||||
class AgentConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.user = self.scope["user"]
|
||||
self.agent_id = self.scope['url_route']['kwargs'].get('agent_id')
|
||||
self.room_group_name = f"agent_{self.agent_id}"
|
||||
|
||||
if not self.user.is_authenticated:
|
||||
await self.close()
|
||||
return
|
||||
|
||||
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
|
||||
await self.accept()
|
||||
await self.send(json.dumps({
|
||||
"type": "connection",
|
||||
"message": "Connected to agent stream",
|
||||
"agent_id": str(self.agent_id)
|
||||
}))
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
|
||||
|
||||
async def receive(self, text_data):
|
||||
try:
|
||||
data = json.loads(text_data)
|
||||
action = data.get('action')
|
||||
|
||||
if action == 'start_agent':
|
||||
await self.handle_start_agent(data)
|
||||
elif action == 'stop_agent':
|
||||
await self.handle_stop_agent(data)
|
||||
else:
|
||||
await self.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": f"Unknown action: {action}"
|
||||
}))
|
||||
except json.JSONDecodeError:
|
||||
await self.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": "Invalid JSON"
|
||||
}))
|
||||
except Exception as e:
|
||||
await self.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": str(e)
|
||||
}))
|
||||
|
||||
async def handle_start_agent(self, data):
|
||||
input_data = data.get('input_data', {})
|
||||
|
||||
agent = await self.get_agent(self.agent_id, self.user)
|
||||
if not agent:
|
||||
await self.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": "Agent not found"
|
||||
}))
|
||||
return
|
||||
|
||||
execution = await self.create_execution(agent, self.user, input_data)
|
||||
|
||||
await self.send(json.dumps({
|
||||
"type": "execution_started",
|
||||
"execution_id": str(execution.uuid),
|
||||
"agent_id": str(agent.uuid),
|
||||
"message": f"Agent execution {execution.uuid} queued"
|
||||
}))
|
||||
|
||||
try:
|
||||
from apps.agents.tasks import start_agent_task_mcp
|
||||
|
||||
print(f"[Consumer] Queuing MCP execution for {execution.uuid}")
|
||||
start_agent_task_mcp.delay(str(execution.uuid))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error queuing agent task: {e}")
|
||||
await self.send(json.dumps({
|
||||
"type": "execution_error",
|
||||
"execution_id": str(execution.uuid),
|
||||
"error_message": str(e)
|
||||
}))
|
||||
|
||||
async def handle_stop_agent(self, data):
|
||||
execution_id = data.get('execution_id')
|
||||
execution = await self.get_execution(execution_id, self.user)
|
||||
|
||||
if not execution:
|
||||
await self.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": "Execution not found"
|
||||
}))
|
||||
return
|
||||
|
||||
await self.update_execution_status(execution, 'failed')
|
||||
await self.send(json.dumps({
|
||||
"type": "execution_stopped",
|
||||
"execution_id": str(execution.uuid),
|
||||
"message": "Agent execution stopped by user"
|
||||
}))
|
||||
|
||||
async def agent_event(self, event):
|
||||
await self.send(json.dumps({
|
||||
"type": "agent_event",
|
||||
"event_type": event['event_type'],
|
||||
"content": event['content'],
|
||||
"timestamp": event['timestamp']
|
||||
}))
|
||||
|
||||
async def agent_completed(self, event):
|
||||
await self.send(json.dumps({
|
||||
"type": "execution_completed",
|
||||
"execution_id": event['execution_id'],
|
||||
"output_data": event['output_data'],
|
||||
"message": "Agent execution completed"
|
||||
}))
|
||||
|
||||
async def agent_error(self, event):
|
||||
await self.send(json.dumps({
|
||||
"type": "execution_error",
|
||||
"execution_id": event['execution_id'],
|
||||
"error_message": event['error_message']
|
||||
}))
|
||||
|
||||
@database_sync_to_async
|
||||
def get_agent(self, agent_id, user):
|
||||
try:
|
||||
return Agent.objects.get(uuid=agent_id, user=user)
|
||||
except Agent.DoesNotExist:
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def get_execution(self, execution_id, user):
|
||||
try:
|
||||
return AgentExecution.objects.get(uuid=execution_id, user=user)
|
||||
except AgentExecution.DoesNotExist:
|
||||
return None
|
||||
|
||||
@database_sync_to_async
|
||||
def create_execution(self, agent, user, input_data):
|
||||
return AgentExecution.objects.create(
|
||||
agent=agent,
|
||||
user=user,
|
||||
input_data=input_data
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def update_execution_status(self, execution, status):
|
||||
execution.status = status
|
||||
execution.save()
|
||||
return execution
|
||||
|
||||
@database_sync_to_async
|
||||
def create_event(self, execution, event_type, content):
|
||||
return AgentEvent.objects.create(
|
||||
execution=execution,
|
||||
event_type=event_type,
|
||||
content=content
|
||||
)
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
# Generated by Django 5.2.8 on 2025-12-17 14:05
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Agent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('status', models.CharField(choices=[('idle', 'Idle'), ('running', 'Running'), ('paused', 'Paused'), ('completed', 'Completed'), ('failed', 'Failed')], default='idle', max_length=20)),
|
||||
('task_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agents', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgentExecution',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('status', models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=20)),
|
||||
('input_data', models.JSONField(default=dict)),
|
||||
('output_data', models.JSONField(blank=True, default=dict)),
|
||||
('error_message', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='executions', to='agents.agent')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agent_executions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AgentEvent',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('event_type', models.CharField(choices=[('started', 'Started'), ('message', 'Message'), ('progress', 'Progress'), ('completed', 'Completed'), ('error', 'Error'), ('step', 'Step')], max_length=20)),
|
||||
('content', models.JSONField()),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('execution', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='agents.agentexecution')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Agent Event',
|
||||
'verbose_name_plural': 'Agent Events',
|
||||
'ordering': ['timestamp'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# Generated by Django 5.2.8 on 2025-12-17 17:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('agents', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='agent',
|
||||
options={'verbose_name': 'Agent', 'verbose_name_plural': 'Agents'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='agentexecution',
|
||||
options={'verbose_name': 'Agent Execution', 'verbose_name_plural': 'Agent Executions'},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from apps.users.models import User
|
||||
import uuid
|
||||
|
||||
|
||||
class Agent(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('idle', 'Idle'),
|
||||
('running', 'Running'),
|
||||
('paused', 'Paused'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
]
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='agents')
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, default="")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='idle')
|
||||
task_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.status})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Agent"
|
||||
verbose_name_plural = "Agents"
|
||||
|
||||
|
||||
class AgentExecution(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
agent = models.ForeignKey(Agent, on_delete=models.CASCADE, related_name='executions')
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='agent_executions')
|
||||
|
||||
status = models.CharField(max_length=20, choices=[
|
||||
('queued', 'Queued'),
|
||||
('running', 'Running'),
|
||||
('completed', 'Completed'),
|
||||
('failed', 'Failed'),
|
||||
], default='queued')
|
||||
|
||||
input_data = models.JSONField(default=dict)
|
||||
output_data = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True, default="")
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Execution {self.uuid} - {self.agent.name} ({self.status})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Agent Execution"
|
||||
verbose_name_plural = "Agent Executions"
|
||||
|
||||
|
||||
class AgentEvent(models.Model):
|
||||
EVENT_TYPES = [
|
||||
('started', 'Started'),
|
||||
('message', 'Message'),
|
||||
('progress', 'Progress'),
|
||||
('completed', 'Completed'),
|
||||
('error', 'Error'),
|
||||
('step', 'Step'),
|
||||
]
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
execution = models.ForeignKey(AgentExecution, on_delete=models.CASCADE, related_name='events')
|
||||
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
|
||||
|
||||
content = models.JSONField()
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.id} - {self.event_type} - {self.execution.agent.name}"
|
||||
|
||||
class Meta:
|
||||
ordering = ['timestamp']
|
||||
verbose_name = "Agent Event"
|
||||
verbose_name_plural = "Agent Events"
|
||||
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
from django.urls import path
|
||||
from apps.agents import consumers
|
||||
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path('ws/agents/<str:agent_id>/', consumers.AgentConsumer.as_asgi()),
|
||||
]
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from rest_framework import serializers
|
||||
from apps.agents.models import Agent, AgentExecution, AgentEvent
|
||||
|
||||
|
||||
class AgentEventSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AgentEvent
|
||||
fields = ['uuid', 'event_type', 'content', 'timestamp']
|
||||
|
||||
|
||||
class AgentExecutionSerializer(serializers.ModelSerializer):
|
||||
events = AgentEventSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AgentExecution
|
||||
fields = ['uuid', 'agent', 'user', 'status', 'input_data', 'output_data', 'error_message', 'created_at', 'started_at', 'completed_at', 'events']
|
||||
read_only_fields = ['uuid', 'created_at', 'started_at', 'completed_at', 'events']
|
||||
|
||||
|
||||
class AgentSerializer(serializers.ModelSerializer):
|
||||
executions = AgentExecutionSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Agent
|
||||
fields = ['uuid', 'user', 'name', 'description', 'status', 'task_id', 'created_at', 'updated_at', 'started_at', 'completed_at', 'executions']
|
||||
read_only_fields = ['uuid', 'user', 'created_at', 'updated_at']
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
from apps.agents.models import Agent, AgentExecution, AgentEvent
|
||||
import json
|
||||
from django.conf import settings
|
||||
import asyncio
|
||||
|
||||
|
||||
@shared_task
|
||||
def start_agent_task_mcp(execution_id):
|
||||
print(f"invoked with execution_id={execution_id}")
|
||||
try:
|
||||
execution = AgentExecution.objects.get(uuid=execution_id)
|
||||
print(f"execution record loaded: agent={execution.agent.uuid}")
|
||||
execution.status = 'running'
|
||||
execution.started_at = timezone.now()
|
||||
execution.save()
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
room_group_name = f"agent_{execution.agent.uuid}"
|
||||
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
room_group_name,
|
||||
{
|
||||
"type": "agent_event",
|
||||
"event_type": "started",
|
||||
"content": {
|
||||
"execution_id": str(execution.uuid),
|
||||
"agent_id": str(execution.agent.uuid),
|
||||
"message": "Agent execution started"
|
||||
},
|
||||
"timestamp": timezone.now().isoformat()
|
||||
}
|
||||
)
|
||||
except Exception as channel_error:
|
||||
print(f"Channel layer error: {channel_error}")
|
||||
|
||||
AgentEvent.objects.create(
|
||||
execution=execution,
|
||||
event_type='started',
|
||||
content={"execution_id": str(execution.uuid), "method": "mcp"}
|
||||
)
|
||||
|
||||
from mcp_agent.mcp_client import MCPAgentClient
|
||||
|
||||
async def execute_remote():
|
||||
async with MCPAgentClient() as client:
|
||||
return await client.execute_agent(
|
||||
agent_id=str(execution.agent.uuid),
|
||||
agent_name=execution.agent.name,
|
||||
execution_id=str(execution.uuid),
|
||||
query=execution.input_data.get("query", ""),
|
||||
input_data=execution.input_data
|
||||
)
|
||||
|
||||
result = asyncio.run(execute_remote())
|
||||
print(f"MCP result: {result.get('status')}")
|
||||
|
||||
if result.get('events'):
|
||||
for event in result['events']:
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
room_group_name,
|
||||
{
|
||||
"type": "agent_event",
|
||||
"event_type": event.get('type', 'message'),
|
||||
"content": event,
|
||||
"timestamp": event.get('timestamp', timezone.now().isoformat())
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error forwarding event: {e}")
|
||||
|
||||
|
||||
if result.get('status') == 'completed':
|
||||
execution.status = 'completed'
|
||||
execution.output_data = result
|
||||
elif result.get('status') in ['failed', 'error']:
|
||||
execution.status = 'failed'
|
||||
execution.error_message = result.get('error', 'Unknown error')
|
||||
execution.output_data = result
|
||||
else:
|
||||
execution.status = 'completed'
|
||||
execution.output_data = result
|
||||
|
||||
execution.completed_at = timezone.now()
|
||||
execution.save()
|
||||
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
room_group_name,
|
||||
{
|
||||
"type": "agent_completed",
|
||||
"execution_id": str(execution.uuid),
|
||||
"output_data": result
|
||||
}
|
||||
)
|
||||
except Exception as channel_error:
|
||||
print(f"Channel layer error: {channel_error}")
|
||||
|
||||
AgentEvent.objects.create(
|
||||
execution=execution,
|
||||
event_type='completed',
|
||||
content={"execution_id": str(execution.uuid), "output": result}
|
||||
)
|
||||
|
||||
except AgentExecution.DoesNotExist:
|
||||
print(f"Execution {execution_id} not found")
|
||||
except Exception as e:
|
||||
print(f"exception: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
try:
|
||||
execution = AgentExecution.objects.get(uuid=execution_id)
|
||||
execution.status = 'failed'
|
||||
execution.error_message = str(e)
|
||||
execution.completed_at = timezone.now()
|
||||
execution.save()
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
room_group_name = f"agent_{execution.agent.uuid}"
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
room_group_name,
|
||||
{
|
||||
"type": "agent_error",
|
||||
"execution_id": str(execution.uuid),
|
||||
"error_message": str(e)
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from apps.agents.models import Agent, AgentExecution
|
||||
from apps.agents.serializers import AgentSerializer, AgentExecutionSerializer
|
||||
from apps.agents.tasks import start_agent_task_mcp
|
||||
|
||||
|
||||
class AgentViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = AgentSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
def get_queryset(self):
|
||||
return Agent.objects.filter(user=self.request.user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def start(self, request, uuid=None):
|
||||
agent = self.get_object()
|
||||
input_data = request.data.get('input_data', {})
|
||||
|
||||
execution = AgentExecution.objects.create(
|
||||
agent=agent,
|
||||
user=request.user,
|
||||
input_data=input_data
|
||||
)
|
||||
|
||||
start_agent_task_mcp.delay(str(execution.uuid))
|
||||
|
||||
serializer = AgentExecutionSerializer(execution)
|
||||
return Response({
|
||||
"status": "queued",
|
||||
"execution": serializer.data,
|
||||
"message": "Agent task queued for execution"
|
||||
})
|
||||
|
||||
|
||||
class AgentExecutionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = AgentExecutionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
def get_queryset(self):
|
||||
return AgentExecution.objects.filter(user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def events(self, request, uuid=None):
|
||||
execution = self.get_object()
|
||||
events = execution.events.all().values()
|
||||
return Response({
|
||||
"execution_id": str(execution.uuid),
|
||||
"events": list(events)
|
||||
})
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
|
||||
|
||||
|
||||
@admin.register(Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'owner', 'uuid', 'created_at', 'updated_at')
|
||||
search_fields = ('name', 'owner__email_address')
|
||||
readonly_fields = ('uuid', 'created_at', 'updated_at')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'uuid', 'description')}),
|
||||
('Ownership', {'fields': ('owner',)}),
|
||||
('Dates', {'fields': ('created_at', 'updated_at')}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(OrganizationMembership)
|
||||
class OrganizationMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'organization', 'role', 'created_at')
|
||||
list_filter = ('role', 'created_at')
|
||||
search_fields = ('user__email_address', 'organization__name')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
@admin.register(InviteToken)
|
||||
class InviteTokenAdmin(admin.ModelAdmin):
|
||||
list_display = ('organization', 'created_by', 'expires_at', 'is_active', 'used_by', 'used_at')
|
||||
list_filter = ('is_active', 'created_at', 'expires_at')
|
||||
search_fields = ('organization__name', 'created_by__email_address', 'token')
|
||||
readonly_fields = ('token', 'created_at', 'updated_at')
|
||||
|
||||
|
||||
@admin.register(Domain)
|
||||
class DomainAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'organization', 'uuid')
|
||||
list_filter = ('organization',)
|
||||
search_fields = ('name', 'organization__name')
|
||||
readonly_fields = ('uuid',)
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'uuid')}),
|
||||
('Description', {'fields': ('description',)}),
|
||||
('Organization', {'fields': ('organization',)}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(DomainMembership)
|
||||
class DomainMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'domain', 'created_at')
|
||||
list_filter = ('created_at',)
|
||||
search_fields = ('user__email_address', 'domain__name')
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
|
||||
@admin.register(Dataset)
|
||||
class DatasetAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'domain', 'uuid', 'created_by', 'created_at')
|
||||
search_fields = ('name', 'domain__name')
|
||||
readonly_fields = ('uuid', 'created_at', 'updated_at')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('name', 'uuid')}),
|
||||
('Details', {'fields': ('domain', 'description', 'created_by')}),
|
||||
('File', {'fields': ('datafile',)}),
|
||||
('Dates', {'fields': ('created_at', 'updated_at')}),
|
||||
)
|
||||
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DomainsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.domains'
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
# Generated by Django 5.2.8 on 2025-12-07 15:22
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Domain',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Dataset',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('datafile', models.FileField(upload_to='datasets/')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_datasets', to=settings.AUTH_USER_MODEL)),
|
||||
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='datasets', to='domains.domain')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Organisation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('domains', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organisations', to='domains.domain')),
|
||||
('employees', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organisations', to=settings.AUTH_USER_MODEL)),
|
||||
('managers', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='managed_organisations', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
# Generated by Django 5.2.8 on 2025-12-17 17:27
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('domains', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='domain',
|
||||
options={'verbose_name': 'Domain', 'verbose_name_plural': 'Domains'},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DomainMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='domains.domain')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domain_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Domain Membership',
|
||||
'verbose_name_plural': 'Domain Memberships',
|
||||
'unique_together': {('user', 'domain')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='members',
|
||||
field=models.ManyToManyField(related_name='domains', through='domains.DomainMembership', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Organization',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('name', models.CharField(max_length=255, unique=True)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_organizations', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organization',
|
||||
'verbose_name_plural': 'Organizations',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InviteToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='created_invites', to=settings.AUTH_USER_MODEL)),
|
||||
('used_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='used_invites', to=settings.AUTH_USER_MODEL)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invite_tokens', to='domains.organization')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Invite Token',
|
||||
'verbose_name_plural': 'Invite Tokens',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='organization',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='domains.organization'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizationMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('role', models.CharField(choices=[('employer', 'Employer'), ('employee', 'Employee')], default='employee', max_length=50)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='domains.organization')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organization_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Organization Membership',
|
||||
'verbose_name_plural': 'Organization Memberships',
|
||||
'unique_together': {('user', 'organization')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='members',
|
||||
field=models.ManyToManyField(related_name='organizations', through='domains.OrganizationMembership', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Organisation',
|
||||
),
|
||||
]
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
from django.db.models import (
|
||||
CASCADE,
|
||||
CharField,
|
||||
FileField,
|
||||
ForeignKey,
|
||||
UUIDField,
|
||||
Model,
|
||||
TextField,
|
||||
ManyToManyField,
|
||||
DateTimeField,
|
||||
BooleanField,
|
||||
TextChoices,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from uuid import uuid4
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.users.models import TimeStampMixin, User
|
||||
|
||||
|
||||
class Organization(TimeStampMixin, Model):
|
||||
|
||||
name = CharField(max_length=255, unique=True)
|
||||
uuid = UUIDField(default=uuid4, editable=False, unique=True)
|
||||
description = TextField(blank=True, default="")
|
||||
owner = ForeignKey(User, on_delete=CASCADE, related_name="owned_organizations")
|
||||
members = ManyToManyField(User, through="OrganizationMembership", related_name="organizations")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organization")
|
||||
verbose_name_plural = _("Organizations")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class OrganizationMembership(TimeStampMixin, Model):
|
||||
|
||||
class Role(TextChoices):
|
||||
EMPLOYER = "employer", _("Employer")
|
||||
EMPLOYEE = "employee", _("Employee")
|
||||
|
||||
user = ForeignKey(User, on_delete=CASCADE, related_name="organization_memberships")
|
||||
organization = ForeignKey(Organization, on_delete=CASCADE, related_name="memberships")
|
||||
role = CharField(max_length=50, choices=Role.choices, default=Role.EMPLOYEE)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Organization Membership")
|
||||
verbose_name_plural = _("Organization Memberships")
|
||||
unique_together = [["user", "organization"]]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.full_name} - {self.organization.name} ({self.role})"
|
||||
|
||||
|
||||
class InviteToken(TimeStampMixin, Model):
|
||||
|
||||
token = UUIDField(default=uuid4, unique=True, editable=False)
|
||||
organization = ForeignKey(Organization, on_delete=CASCADE, related_name="invite_tokens")
|
||||
created_by = ForeignKey(User, on_delete=CASCADE, related_name="created_invites")
|
||||
expires_at = DateTimeField()
|
||||
used_by = ForeignKey(User, on_delete=CASCADE, null=True, blank=True, related_name="used_invites")
|
||||
used_at = DateTimeField(null=True, blank=True)
|
||||
is_active = BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Invite Token")
|
||||
verbose_name_plural = _("Invite Tokens")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.expires_at:
|
||||
self.expires_at = timezone.now() + timedelta(days=7)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def is_valid(self):
|
||||
return self.is_active and not self.used_by and timezone.now() < self.expires_at
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Invite for {self.organization.name} (expires {self.expires_at})"
|
||||
|
||||
|
||||
class Domain(Model):
|
||||
|
||||
name = CharField(max_length=255, unique=True)
|
||||
uuid = UUIDField(default=uuid4, editable=False, unique=True)
|
||||
description = TextField(blank=True, default="")
|
||||
organization = ForeignKey(Organization, on_delete=CASCADE, related_name="domains", null=True, blank=True)
|
||||
members = ManyToManyField(User, through="DomainMembership", related_name="domains")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Domain")
|
||||
verbose_name_plural = _("Domains")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class DomainMembership(TimeStampMixin, Model):
|
||||
|
||||
user = ForeignKey(User, on_delete=CASCADE, related_name="domain_memberships")
|
||||
domain = ForeignKey(Domain, on_delete=CASCADE, related_name="memberships")
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Domain Membership")
|
||||
verbose_name_plural = _("Domain Memberships")
|
||||
unique_together = [["user", "domain"]]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.full_name} - {self.domain.name}"
|
||||
|
||||
class Dataset(TimeStampMixin, Model):
|
||||
|
||||
domain = ForeignKey(Domain, on_delete = CASCADE, related_name = "datasets")
|
||||
name = CharField(max_length = 255)
|
||||
uuid = UUIDField(default = uuid4, editable = False, unique = True)
|
||||
description = TextField(blank = True, default = "")
|
||||
created_by = ForeignKey(User, on_delete = CASCADE, related_name = "created_datasets")
|
||||
datafile = FileField(upload_to = "datasets/")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.domain.name})"
|
||||
|
||||
Organisation = Organization
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
|
||||
from apps.users.serializers import UserSerializer
|
||||
|
||||
|
||||
class OrganizationSerializer(serializers.ModelSerializer):
|
||||
owner = UserSerializer(read_only=True)
|
||||
member_count = serializers.SerializerMethodField()
|
||||
domain_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Organization
|
||||
fields = ['id', 'uuid', 'name', 'description', 'owner', 'created_at', 'updated_at', 'member_count', 'domain_count']
|
||||
read_only_fields = ['uuid', 'owner', 'created_at', 'updated_at']
|
||||
|
||||
def get_member_count(self, obj):
|
||||
return obj.memberships.count()
|
||||
|
||||
def get_domain_count(self, obj):
|
||||
return obj.domains.count()
|
||||
|
||||
|
||||
class OrganizationMembershipSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
user_id = serializers.IntegerField(write_only=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = OrganizationMembership
|
||||
fields = ['id', 'user', 'user_id', 'organization', 'role', 'created_at']
|
||||
read_only_fields = ['organization', 'created_at']
|
||||
|
||||
|
||||
class InviteTokenSerializer(serializers.ModelSerializer):
|
||||
created_by = UserSerializer(read_only=True)
|
||||
used_by = UserSerializer(read_only=True)
|
||||
invite_url = serializers.SerializerMethodField()
|
||||
is_valid = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = InviteToken
|
||||
fields = ['id', 'token', 'organization', 'created_by', 'expires_at', 'used_by', 'used_at', 'is_active', 'invite_url', 'is_valid', 'created_at']
|
||||
read_only_fields = ['token', 'organization', 'created_by', 'used_by', 'used_at', 'created_at']
|
||||
|
||||
def get_invite_url(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(f'/invite/{obj.token}')
|
||||
return f'/invite/{obj.token}'
|
||||
|
||||
def get_is_valid(self, obj):
|
||||
return obj.is_valid()
|
||||
|
||||
|
||||
class DomainMembershipSerializer(serializers.ModelSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
domain_name = serializers.CharField(source='domain.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DomainMembership
|
||||
fields = ['id', 'user', 'domain', 'domain_name', 'created_at']
|
||||
read_only_fields = ['created_at']
|
||||
|
||||
|
||||
class DomainSerializer(ModelSerializer):
|
||||
organization = OrganizationSerializer(read_only=True)
|
||||
organization_id = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
||||
member_count = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Domain
|
||||
fields = ['id', 'uuid', 'name', 'description', 'organization', 'organization_id', 'member_count']
|
||||
read_only_fields = ['uuid']
|
||||
|
||||
def get_member_count(self, obj):
|
||||
return obj.memberships.count()
|
||||
|
||||
|
||||
class DatasetSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Dataset
|
||||
fields = ['id', 'domain', 'name', 'description', 'uuid', 'created_by', 'datafile', 'created_at', 'updated_at']
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from apps.domains.models import Domain, Organisation, Dataset
|
||||
from apps.users.models import User
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class DomainTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.domain1 = Domain.objects.create(name="Python", description="Python Programming")
|
||||
self.domain2 = Domain.objects.create(name="JavaScript", description="JavaScript Development")
|
||||
|
||||
def test_domain_creation(self):
|
||||
self.assertEqual(self.domain1.name, "Python")
|
||||
self.assertEqual(self.domain2.name, "JavaScript")
|
||||
|
||||
def test_domain_string_representation(self):
|
||||
self.assertEqual(str(self.domain1), "Python")
|
||||
self.assertEqual(str(self.domain2), "JavaScript")
|
||||
|
||||
def test_domain_name_unique(self):
|
||||
with self.assertRaises(Exception):
|
||||
Domain.objects.create(name="Python", description="Duplicate")
|
||||
|
||||
def test_domain_description_blank(self):
|
||||
domain = Domain.objects.create(name="Java")
|
||||
self.assertEqual(domain.description, "")
|
||||
|
||||
def test_domain_description_optional(self):
|
||||
domain = Domain.objects.create(name="Rust", description="System Programming")
|
||||
self.assertIsNotNone(domain.description)
|
||||
|
||||
def test_domain_uuid_generated(self):
|
||||
self.assertIsNotNone(self.domain1.uuid)
|
||||
self.assertIsNotNone(self.domain2.uuid)
|
||||
|
||||
def test_domain_uuid_unique(self):
|
||||
uuid1 = self.domain1.uuid
|
||||
uuid2 = self.domain2.uuid
|
||||
self.assertNotEqual(uuid1, uuid2)
|
||||
|
||||
def test_domain_uuid_immutable(self):
|
||||
original_uuid = self.domain1.uuid
|
||||
self.domain1.save()
|
||||
self.assertEqual(self.domain1.uuid, original_uuid)
|
||||
|
||||
def test_domain_count(self):
|
||||
self.assertEqual(Domain.objects.count(), 2)
|
||||
|
||||
def test_domain_filter_by_name(self):
|
||||
domain = Domain.objects.get(name="Python")
|
||||
self.assertEqual(domain.id, self.domain1.id)
|
||||
|
||||
def test_domain_filter_by_uuid(self):
|
||||
domain = Domain.objects.get(uuid=self.domain1.uuid)
|
||||
self.assertEqual(domain.name, "Python")
|
||||
|
||||
def test_domain_update_name(self):
|
||||
self.domain1.name = "Python3"
|
||||
self.domain1.save()
|
||||
updated = Domain.objects.get(id=self.domain1.id)
|
||||
self.assertEqual(updated.name, "Python3")
|
||||
|
||||
def test_domain_update_description(self):
|
||||
self.domain1.description = "Advanced Python"
|
||||
self.domain1.save()
|
||||
updated = Domain.objects.get(id=self.domain1.id)
|
||||
self.assertEqual(updated.description, "Advanced Python")
|
||||
|
||||
def test_domain_delete(self):
|
||||
domain_id = self.domain1.id
|
||||
self.domain1.delete()
|
||||
with self.assertRaises(Domain.DoesNotExist):
|
||||
Domain.objects.get(id=domain_id)
|
||||
|
||||
def test_domain_all_fields(self):
|
||||
self.assertTrue(hasattr(self.domain1, 'name'))
|
||||
self.assertTrue(hasattr(self.domain1, 'uuid'))
|
||||
self.assertTrue(hasattr(self.domain1, 'description'))
|
||||
|
||||
def test_domain_max_length_name(self):
|
||||
long_name = "a" * 255
|
||||
domain = Domain.objects.create(name=long_name)
|
||||
self.assertEqual(domain.name, long_name)
|
||||
|
||||
def test_domain_default_description(self):
|
||||
domain = Domain.objects.create(name="Go")
|
||||
self.assertEqual(domain.description, "")
|
||||
|
||||
|
||||
class OrganisationTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user1 = User.objects.create_user(email_address="manager@test.com", password="pass123")
|
||||
self.user2 = User.objects.create_user(email_address="employee@test.com", password="pass123")
|
||||
self.domain = Domain.objects.create(name="Technology")
|
||||
# Organization model uses `owner` and `members`.
|
||||
self.org1 = Organisation.objects.create(
|
||||
name="TechCorp",
|
||||
owner=self.user1,
|
||||
)
|
||||
# add member and link domain
|
||||
self.org1.members.add(self.user2)
|
||||
self.domain.organization = self.org1
|
||||
self.domain.save()
|
||||
|
||||
def test_organisation_creation(self):
|
||||
self.assertEqual(self.org1.name, "TechCorp")
|
||||
|
||||
def test_organisation_string_representation(self):
|
||||
self.assertEqual(str(self.org1), "TechCorp")
|
||||
|
||||
def test_organisation_name_unique(self):
|
||||
with self.assertRaises(Exception):
|
||||
Organisation.objects.create(
|
||||
name="TechCorp",
|
||||
owner=self.user1,
|
||||
)
|
||||
|
||||
def test_organisation_manager_relationship(self):
|
||||
self.assertEqual(self.org1.owner, self.user1)
|
||||
|
||||
def test_organisation_employee_relationship(self):
|
||||
self.assertTrue(self.org1.members.filter(pk=self.user2.pk).exists())
|
||||
|
||||
def test_organisation_domain_relationship(self):
|
||||
self.assertTrue(self.org1.domains.filter(pk=self.domain.pk).exists())
|
||||
|
||||
def test_organisation_uuid_generated(self):
|
||||
self.assertIsNotNone(self.org1.uuid)
|
||||
|
||||
def test_organisation_timestamps(self):
|
||||
self.assertIsNotNone(self.org1.created_at)
|
||||
self.assertIsNotNone(self.org1.updated_at)
|
||||
|
||||
def test_organisation_created_at_updated_at_close_on_creation(self):
|
||||
delta = abs((self.org1.created_at - self.org1.updated_at).total_seconds())
|
||||
self.assertLess(delta, 1)
|
||||
|
||||
def test_organisation_update_changes_updated_at(self):
|
||||
original_updated = self.org1.updated_at
|
||||
import time
|
||||
time.sleep(0.1)
|
||||
self.org1.name = "TechCorp Updated"
|
||||
self.org1.save()
|
||||
self.assertGreater(self.org1.updated_at, original_updated)
|
||||
|
||||
def test_organisation_count(self):
|
||||
self.assertEqual(Organisation.objects.count(), 1)
|
||||
|
||||
def test_organisation_filter_by_name(self):
|
||||
org = Organisation.objects.get(name="TechCorp")
|
||||
self.assertEqual(org.id, self.org1.id)
|
||||
|
||||
def test_organisation_filter_by_manager(self):
|
||||
orgs = Organisation.objects.filter(owner=self.user1)
|
||||
self.assertEqual(orgs.count(), 1)
|
||||
|
||||
def test_organisation_delete_cascade(self):
|
||||
org_id = self.org1.id
|
||||
self.org1.delete()
|
||||
with self.assertRaises(Organisation.DoesNotExist):
|
||||
Organisation.objects.get(id=org_id)
|
||||
|
||||
def test_organisation_update_name(self):
|
||||
self.org1.name = "NewTechCorp"
|
||||
self.org1.save()
|
||||
updated = Organisation.objects.get(id=self.org1.id)
|
||||
self.assertEqual(updated.name, "NewTechCorp")
|
||||
|
||||
|
||||
class DatasetTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(email_address="creator@test.com", password="pass123")
|
||||
self.domain = Domain.objects.create(name="ML")
|
||||
self.dataset1 = Dataset.objects.create(
|
||||
domain=self.domain,
|
||||
name="Training Data",
|
||||
description="Training dataset for ML",
|
||||
created_by=self.user
|
||||
)
|
||||
|
||||
def test_dataset_creation(self):
|
||||
self.assertEqual(self.dataset1.name, "Training Data")
|
||||
|
||||
def test_dataset_string_representation(self):
|
||||
self.assertEqual(str(self.dataset1), "Training Data (ML)")
|
||||
|
||||
def test_dataset_domain_relationship(self):
|
||||
self.assertEqual(self.dataset1.domain, self.domain)
|
||||
|
||||
def test_dataset_created_by_relationship(self):
|
||||
self.assertEqual(self.dataset1.created_by, self.user)
|
||||
|
||||
def test_dataset_description_optional(self):
|
||||
dataset = Dataset.objects.create(
|
||||
domain=self.domain,
|
||||
name="Test Data",
|
||||
created_by=self.user
|
||||
)
|
||||
self.assertEqual(dataset.description, "")
|
||||
|
||||
def test_dataset_uuid_generated(self):
|
||||
self.assertIsNotNone(self.dataset1.uuid)
|
||||
|
||||
def test_dataset_timestamps(self):
|
||||
self.assertIsNotNone(self.dataset1.created_at)
|
||||
self.assertIsNotNone(self.dataset1.updated_at)
|
||||
|
||||
def test_dataset_count(self):
|
||||
self.assertEqual(Dataset.objects.count(), 1)
|
||||
|
||||
def test_dataset_filter_by_domain(self):
|
||||
datasets = Dataset.objects.filter(domain=self.domain)
|
||||
self.assertEqual(datasets.count(), 1)
|
||||
|
||||
def test_dataset_filter_by_creator(self):
|
||||
datasets = Dataset.objects.filter(created_by=self.user)
|
||||
self.assertEqual(datasets.count(), 1)
|
||||
|
||||
def test_dataset_filter_by_name(self):
|
||||
dataset = Dataset.objects.get(name="Training Data")
|
||||
self.assertEqual(dataset.id, self.dataset1.id)
|
||||
|
||||
def test_dataset_update_description(self):
|
||||
self.dataset1.description = "Updated description"
|
||||
self.dataset1.save()
|
||||
updated = Dataset.objects.get(id=self.dataset1.id)
|
||||
self.assertEqual(updated.description, "Updated description")
|
||||
|
||||
def test_dataset_delete(self):
|
||||
dataset_id = self.dataset1.id
|
||||
self.dataset1.delete()
|
||||
with self.assertRaises(Dataset.DoesNotExist):
|
||||
Dataset.objects.get(id=dataset_id)
|
||||
|
||||
def test_dataset_multiple_per_domain(self):
|
||||
dataset2 = Dataset.objects.create(
|
||||
domain=self.domain,
|
||||
name="Test Data",
|
||||
created_by=self.user
|
||||
)
|
||||
datasets = Dataset.objects.filter(domain=self.domain)
|
||||
self.assertEqual(datasets.count(), 2)
|
||||
|
||||
def test_dataset_multiple_per_creator(self):
|
||||
dataset2 = Dataset.objects.create(
|
||||
domain=self.domain,
|
||||
name="Test Data 2",
|
||||
created_by=self.user
|
||||
)
|
||||
datasets = Dataset.objects.filter(created_by=self.user)
|
||||
self.assertEqual(datasets.count(), 2)
|
||||
|
||||
def test_dataset_cascade_on_domain_delete(self):
|
||||
dataset_id = self.dataset1.id
|
||||
self.domain.delete()
|
||||
with self.assertRaises(Dataset.DoesNotExist):
|
||||
Dataset.objects.get(id=dataset_id)
|
||||
|
||||
def test_dataset_cascade_on_user_delete(self):
|
||||
dataset_id = self.dataset1.id
|
||||
self.user.delete()
|
||||
with self.assertRaises(Dataset.DoesNotExist):
|
||||
Dataset.objects.get(id=dataset_id)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
|
||||
from apps.domains.serializers import (
|
||||
DomainSerializer,
|
||||
OrganizationSerializer,
|
||||
DatasetSerializer,
|
||||
OrganizationMembershipSerializer,
|
||||
InviteTokenSerializer,
|
||||
DomainMembershipSerializer,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationViewSet(ModelViewSet):
|
||||
queryset = Organization.objects.all()
|
||||
serializer_class = OrganizationSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return Organization.objects.filter(memberships__user=user).distinct()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
org = serializer.save(owner=self.request.user)
|
||||
OrganizationMembership.objects.create(
|
||||
organization=org,
|
||||
user=self.request.user,
|
||||
role=OrganizationMembership.Role.EMPLOYER
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
org = self.get_object()
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
organization=org,
|
||||
user=request.user,
|
||||
role=OrganizationMembership.Role.EMPLOYER
|
||||
).first()
|
||||
if not membership:
|
||||
return Response(
|
||||
{"error": "Only employers can update organization details"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def members(self, request, uuid=None):
|
||||
org = self.get_object()
|
||||
memberships = org.memberships.all()
|
||||
serializer = OrganizationMembershipSerializer(memberships, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['patch'], url_path='members/(?P<user_id>[^/.]+)')
|
||||
def update_member(self, request, uuid=None, user_id=None):
|
||||
org = self.get_object()
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
organization=org,
|
||||
user=request.user,
|
||||
role=OrganizationMembership.Role.EMPLOYER
|
||||
).first()
|
||||
if not membership:
|
||||
return Response(
|
||||
{"error": "Only employers can update member roles"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id)
|
||||
serializer = OrganizationMembershipSerializer(target_membership, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['delete'], url_path='members/(?P<user_id>[^/.]+)')
|
||||
def remove_member(self, request, uuid=None, user_id=None):
|
||||
org = self.get_object()
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
organization=org,
|
||||
user=request.user,
|
||||
role=OrganizationMembership.Role.EMPLOYER
|
||||
).first()
|
||||
if not membership:
|
||||
return Response(
|
||||
{"error": "Only employers can remove members"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id)
|
||||
if target_membership.user == org.owner:
|
||||
return Response(
|
||||
{"error": "Cannot remove the organization owner"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
target_membership.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=['get', 'post'])
|
||||
def invites(self, request, uuid=None):
|
||||
org = self.get_object()
|
||||
|
||||
if request.method == 'GET':
|
||||
tokens = org.invite_tokens.filter(is_active=True, used_by__isnull=True)
|
||||
serializer = InviteTokenSerializer(tokens, many=True, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
elif request.method == 'POST':
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
organization=org,
|
||||
user=request.user,
|
||||
role=OrganizationMembership.Role.EMPLOYER
|
||||
).first()
|
||||
if not membership:
|
||||
return Response(
|
||||
{"error": "Only employers can create invites"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
token = InviteToken.objects.create(
|
||||
organization=org,
|
||||
created_by=request.user
|
||||
)
|
||||
serializer = InviteTokenSerializer(token, context={'request': request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['delete'], url_path='invites/(?P<token>[^/.]+)')
|
||||
def revoke_invite(self, request, uuid=None, token=None):
|
||||
org = self.get_object()
|
||||
membership = OrganizationMembership.objects.filter(
|
||||
organization=org,
|
||||
user=request.user,
|
||||
role=OrganizationMembership.Role.EMPLOYER
|
||||
).first()
|
||||
if not membership:
|
||||
return Response(
|
||||
{"error": "Only employers can revoke invites"},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
invite = get_object_or_404(InviteToken, organization=org, token=token)
|
||||
invite.is_active = False
|
||||
invite.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def domains(self, request, uuid=None):
|
||||
org = self.get_object()
|
||||
domains = org.domains.all()
|
||||
serializer = DomainSerializer(domains, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='domains/(?P<domain_id>[^/.]+)/members')
|
||||
def domain_members(self, request, uuid=None, domain_id=None):
|
||||
org = self.get_object()
|
||||
domain = get_object_or_404(Domain, organization=org, id=domain_id)
|
||||
memberships = domain.memberships.all()
|
||||
serializer = DomainMembershipSerializer(memberships, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='domains/(?P<domain_id>[^/.]+)/members')
|
||||
def add_domain_member(self, request, uuid=None, domain_id=None):
|
||||
org = self.get_object()
|
||||
domain = get_object_or_404(Domain, organization=org, id=domain_id)
|
||||
|
||||
user_id = request.data.get('user_id')
|
||||
org_membership = OrganizationMembership.objects.filter(
|
||||
organization=org,
|
||||
user_id=user_id
|
||||
).first()
|
||||
|
||||
if not org_membership:
|
||||
return Response(
|
||||
{"error": "User must be a member of the organization first"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
domain_membership, created = DomainMembership.objects.get_or_create(
|
||||
domain=domain,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
serializer = DomainMembershipSerializer(domain_membership)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||
|
||||
|
||||
class InviteViewSet(ModelViewSet):
|
||||
queryset = InviteToken.objects.all()
|
||||
serializer_class = InviteTokenSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
lookup_field = 'token'
|
||||
http_method_names = ['get', 'post']
|
||||
|
||||
def get_queryset(self):
|
||||
return InviteToken.objects.filter(is_active=True, used_by__isnull=True)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def accept(self, request, token=None):
|
||||
invite = self.get_object()
|
||||
|
||||
if not invite.is_valid():
|
||||
return Response(
|
||||
{"error": "This invite is no longer valid"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
membership, created = OrganizationMembership.objects.get_or_create(
|
||||
organization=invite.organization,
|
||||
user=request.user,
|
||||
defaults={'role': OrganizationMembership.Role.EMPLOYEE}
|
||||
)
|
||||
|
||||
if created:
|
||||
invite.used_by = request.user
|
||||
invite.used_at = timezone.now()
|
||||
invite.is_active = False
|
||||
invite.save()
|
||||
|
||||
serializer = OrganizationSerializer(invite.organization)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||
|
||||
|
||||
class DomainViewSet(ModelViewSet):
|
||||
queryset = Domain.objects.all()
|
||||
serializer_class = DomainSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_authenticated:
|
||||
return Domain.objects.filter(
|
||||
organization__memberships__user=user
|
||||
).distinct()
|
||||
return Domain.objects.none()
|
||||
|
||||
|
||||
class DatasetViewSet(ModelViewSet):
|
||||
queryset = Dataset.objects.all()
|
||||
serializer_class = DatasetSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.contrib.auth.models import Group
|
||||
from apps.users.models import User
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(DjangoUserAdmin):
|
||||
fieldsets = (
|
||||
(None, {'fields': ('email_address', 'password')}),
|
||||
('Personal info', {'fields': ('first_name', 'last_name')}),
|
||||
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'role')}),
|
||||
('Dates', {'fields': ('last_login',)}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email_address', 'first_name', 'last_name', 'password1', 'password2'),
|
||||
}),
|
||||
)
|
||||
|
||||
list_display = ('email_address', 'first_name', 'last_name', 'is_staff')
|
||||
search_fields = ('email_address', 'first_name', 'last_name')
|
||||
ordering = ('email_address',)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.users'
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.models import BaseUserManager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from apps.users.models import User
|
||||
|
||||
class UserManager(BaseUserManager["User"]):
|
||||
|
||||
def _create_user(self, email_address: str, password: str | None, **extra_fields):
|
||||
if not email_address:
|
||||
raise ValueError("The given email must be set")
|
||||
email_address = self.normalize_email(email_address)
|
||||
user: User = self.model(email_address=email_address, **extra_fields)
|
||||
user.password = make_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email_address: str, password: str | None = None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
return self._create_user(email_address, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email_address: str, password: str | None = None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
return self._create_user(email_address, password, **extra_fields)
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Generated by Django 5.2.8 on 2025-12-06 21:33
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('id', models.AutoField(primary_key=True, serialize=False, verbose_name='User ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='User UUID')),
|
||||
('email_address', models.EmailField(max_length=255, unique=True, verbose_name='Email Address')),
|
||||
('first_name', models.CharField(max_length=255, verbose_name='First Name')),
|
||||
('last_name', models.CharField(max_length=255, verbose_name='Last Name')),
|
||||
('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')),
|
||||
('bio', models.TextField(blank=True, default='')),
|
||||
('timezone', models.CharField(blank=True, default='UTC', max_length=16)),
|
||||
('avatar_url', models.URLField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Account Active')),
|
||||
('is_staff', models.BooleanField(default=False, verbose_name='Account Admin')),
|
||||
('role', models.CharField(choices=[('manager', 'Manager'), ('employee', 'Employee')], default='employee', max_length=50, verbose_name='Role')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
||||
from django.db.models import (
|
||||
AutoField,
|
||||
BooleanField,
|
||||
CharField,
|
||||
DateField,
|
||||
DateTimeField,
|
||||
EmailField,
|
||||
UUIDField,
|
||||
Model,
|
||||
TextChoices,
|
||||
TextField,
|
||||
URLField,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from typing import ClassVar
|
||||
from uuid import uuid4
|
||||
from apps.users.managers import UserManager
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class TimeStampMixin(Model):
|
||||
|
||||
created_at = DateTimeField(verbose_name="Created At", auto_now_add=True)
|
||||
updated_at = DateTimeField(verbose_name="Updated At", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class User(AbstractBaseUser, TimeStampMixin, PermissionsMixin):
|
||||
|
||||
class Roles(TextChoices):
|
||||
MANAGER = 'manager', _("Manager")
|
||||
EMPLOYEE = 'employee', _("Employee")
|
||||
|
||||
id = AutoField(verbose_name = _("User ID"), primary_key = True)
|
||||
uuid = UUIDField(verbose_name = _("User UUID"), default = uuid4, editable = False)
|
||||
|
||||
email_address = EmailField(verbose_name = _("Email Address"), max_length = 255, unique = True)
|
||||
first_name = CharField(verbose_name = _("First Name"), max_length = 255)
|
||||
last_name = CharField(verbose_name = _("Last Name"), max_length = 255)
|
||||
date_of_birth = DateField(verbose_name = _("Date of Birth"), null = True, blank = True)
|
||||
|
||||
bio = TextField(default = "", blank = True)
|
||||
timezone = CharField(default = settings.TIME_ZONE, max_length = 16, blank = True)
|
||||
avatar_url = URLField(blank = True)
|
||||
|
||||
is_active = BooleanField(verbose_name = _("Account Active"), default = True)
|
||||
is_staff = BooleanField(verbose_name = _("Account Admin"), default = False)
|
||||
|
||||
role = CharField(verbose_name = _("Role"), max_length = 50, choices = Roles.choices, default = Roles.EMPLOYEE)
|
||||
|
||||
USERNAME_FIELD = 'email_address'
|
||||
EMAIL_FIELD = 'email_address'
|
||||
REQUIRED_FIELDS = ['first_name', 'last_name', 'date_of_birth']
|
||||
|
||||
objects: ClassVar[UserManager] = UserManager()
|
||||
|
||||
def has_perm(self, perm, obj=None):
|
||||
return True
|
||||
|
||||
def has_module_perms(self, app_label):
|
||||
return True
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('User')
|
||||
verbose_name_plural = _('Users')
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name
|
||||
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from rest_framework import serializers
|
||||
from apps.users.models import User
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'uuid', 'email_address', 'first_name', 'last_name', 'bio', 'timezone', 'avatar_url', 'role', 'date_of_birth', 'created_at', 'updated_at']
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserListAPITests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(
|
||||
password='pass1234',
|
||||
email_address='apiuser@example.com',
|
||||
first_name='API',
|
||||
last_name='User',
|
||||
date_of_birth='1995-05-05',
|
||||
)
|
||||
|
||||
def test_list_users(self):
|
||||
url = '/api/user/'
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
data = resp.json()
|
||||
self.assertIsInstance(data, (list, dict))
|
||||
|
||||
def test_api_response_contains_expected_fields(self):
|
||||
url = '/api/user/'
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
data = resp.json()
|
||||
|
||||
if isinstance(data, dict) and 'results' in data:
|
||||
users = data['results']
|
||||
else:
|
||||
users = data
|
||||
|
||||
self.assertTrue(len(users) >= 1)
|
||||
sample = users[0]
|
||||
expected_keys = {'id', 'uuid', 'email_address', 'first_name', 'last_name', 'bio', 'timezone', 'avatar_url'}
|
||||
self.assertTrue(expected_keys.issubset(set(sample.keys())))
|
||||
|
||||
def test_retrieve_user_by_uuid(self):
|
||||
url = f'/api/user/{self.user.uuid}/'
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
data = resp.json()
|
||||
self.assertEqual(data['email_address'], 'apiuser@example.com')
|
||||
|
||||
def test_retrieve_user_not_found(self):
|
||||
import uuid
|
||||
fake_uuid = uuid.uuid4()
|
||||
url = f'/api/user/{fake_uuid}/'
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
|
@ -1,641 +0,0 @@
|
|||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserLoginActionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user_data = {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123',
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User',
|
||||
'date_of_birth': '1990-01-01'
|
||||
}
|
||||
self.user = User.objects.create_user(**self.user_data)
|
||||
|
||||
def test_login_successful(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(data['message'], 'Login successful')
|
||||
self.assertIn('user', data)
|
||||
self.assertEqual(data['user']['email_address'], 'testuser@example.com')
|
||||
|
||||
def test_login_missing_email(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertIn('error', data)
|
||||
|
||||
def test_login_missing_password(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertIn('error', data)
|
||||
|
||||
def test_login_invalid_credentials(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'wrongpassword'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_login_nonexistent_user(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'nonexistent@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_login_session_created(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('sessionid', self.client.cookies)
|
||||
|
||||
def test_login_inactive_user(self):
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_login_case_insensitive_email(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@EXAMPLE.COM',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserLogoutActionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(
|
||||
email_address='testuser@example.com',
|
||||
password='testpass123',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
|
||||
def test_logout_successful(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/logout/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
def test_logout_without_login(self):
|
||||
response = self.client.post('/api/user/logout/')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_session_destroyed_after_logout(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.client.post('/api/user/logout/')
|
||||
response = self.client.get('/api/user/me/')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
class UserMeActionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(
|
||||
email_address='testuser@example.com',
|
||||
password='testpass123',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
|
||||
def test_me_authenticated(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.get('/api/user/me/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(data['email_address'], 'testuser@example.com')
|
||||
|
||||
def test_me_unauthenticated(self):
|
||||
response = self.client.get('/api/user/me/')
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_me_returns_correct_user_data(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.get('/api/user/me/')
|
||||
data = response.json()
|
||||
expected_fields = {'id', 'uuid', 'email_address', 'first_name', 'last_name'}
|
||||
self.assertTrue(expected_fields.issubset(set(data.keys())))
|
||||
|
||||
|
||||
class UserSessionActionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(
|
||||
email_address='testuser@example.com',
|
||||
password='testpass123',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
|
||||
def test_session_authenticated(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.get('/api/user/session/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertTrue(data['isAuthenticated'])
|
||||
|
||||
def test_session_unauthenticated(self):
|
||||
response = self.client.get('/api/user/session/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertFalse(data['isAuthenticated'])
|
||||
|
||||
def test_session_staff_status(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.get('/api/user/session/')
|
||||
data = response.json()
|
||||
self.assertIn('isStaff', data)
|
||||
self.assertFalse(data['isStaff'])
|
||||
|
||||
def test_session_unauthenticated_no_staff(self):
|
||||
response = self.client.get('/api/user/session/')
|
||||
data = response.json()
|
||||
self.assertFalse(data['isAuthenticated'])
|
||||
|
||||
|
||||
class UserSignupActionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
|
||||
def test_signup_successful(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'newuser@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User',
|
||||
'date_of_birth': '1995-05-05'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
data = response.json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIn('User account created successfully', data['detail'])
|
||||
self.assertTrue(User.objects.filter(email_address='newuser@example.com').exists())
|
||||
|
||||
def test_signup_email_exists(self):
|
||||
User.objects.create_user(
|
||||
email_address='existing@example.com',
|
||||
password='pass',
|
||||
first_name='Existing',
|
||||
last_name='User'
|
||||
)
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'existing@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertFalse(data['success'])
|
||||
self.assertIn('Email address already exists', data['detail'])
|
||||
|
||||
def test_signup_missing_first_name(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'newuser2@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_signup_missing_last_name(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'newuser3@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_signup_passwords_mismatch(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'newuser4@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'differentpass',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertIn('Passwords do not match', data['detail'])
|
||||
|
||||
def test_signup_missing_email(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_signup_missing_password(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'newuser@example.com',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_signup_empty_data(self):
|
||||
response = self.client.post('/api/user/signup/', {})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_signup_case_insensitive_email(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'NewUser@EXAMPLE.COM',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
user = User.objects.get(email_address='NewUser@example.com')
|
||||
self.assertEqual(user.email_address, 'NewUser@example.com')
|
||||
|
||||
def test_signup_duplicate_case_insensitive(self):
|
||||
User.objects.create_user(
|
||||
email_address='test@example.com',
|
||||
password='pass',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'New',
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserChangePasswordActionTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(
|
||||
email_address='testuser@example.com',
|
||||
password='testpass123',
|
||||
first_name='Test',
|
||||
last_name='User'
|
||||
)
|
||||
|
||||
def test_change_password_successful(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'password': 'newpass456',
|
||||
'confirm_password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertTrue(data['success'])
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(self.user.check_password('newpass456'))
|
||||
|
||||
def test_change_password_wrong_old_password(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'wrongoldpass',
|
||||
'password': 'newpass456',
|
||||
'confirm_password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
data = response.json()
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_change_password_mismatch(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'password': 'newpass456',
|
||||
'confirm_password': 'differentpass'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertIn('Passwords do not match', data['detail'])
|
||||
|
||||
def test_change_password_missing_old_password(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'password': 'newpass456',
|
||||
'confirm_password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
data = response.json()
|
||||
self.assertIn('old_password', data['detail'])
|
||||
|
||||
def test_change_password_missing_new_password(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'confirm_password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_change_password_unauthenticated(self):
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'password': 'newpass456',
|
||||
'confirm_password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_change_password_empty_old_password(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': '',
|
||||
'password': 'newpass456',
|
||||
'confirm_password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_can_login_with_new_password_after_change(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'password': 'brandnewpass789',
|
||||
'confirm_password': 'brandnewpass789'
|
||||
})
|
||||
self.client.logout()
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'testuser@example.com',
|
||||
'password': 'brandnewpass789'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UserEdgeCaseTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.user = User.objects.create_user(
|
||||
email_address='edgecase@example.com',
|
||||
password='testpass123',
|
||||
first_name='Edge',
|
||||
last_name='Case'
|
||||
)
|
||||
|
||||
def test_login_with_whitespace_email(self):
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': ' testuser@example.com ',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_signup_with_very_long_name(self):
|
||||
long_name = 'A' * 255
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'longname@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': long_name,
|
||||
'last_name': long_name
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_signup_with_too_long_name(self):
|
||||
too_long_name = 'A' * 256
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'verylongname@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': too_long_name,
|
||||
'last_name': 'User'
|
||||
})
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_201_CREATED])
|
||||
|
||||
def test_signup_with_special_characters_in_name(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'special@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'José',
|
||||
'last_name': "O'Brien-Smith"
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_change_password_same_as_old(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'password': 'testpass123',
|
||||
'confirm_password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_signup_missing_confirm_password_field(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'missingconfirm@example.com',
|
||||
'password': 'newpass123',
|
||||
'first_name': 'Missing',
|
||||
'last_name': 'Confirm'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_login_multiple_times_same_session(self):
|
||||
response1 = self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
session_id_1 = self.client.cookies.get('sessionid')
|
||||
|
||||
me1 = self.client.get('/api/user/me/')
|
||||
self.assertEqual(me1.status_code, status.HTTP_200_OK)
|
||||
|
||||
response2 = self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
session_id_2 = self.client.cookies.get('sessionid')
|
||||
self.assertEqual(response1.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response2.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_staff_user_login_shows_staff_status(self):
|
||||
staff_user = User.objects.create_user(
|
||||
email_address='staff@example.com',
|
||||
password='staffpass',
|
||||
first_name='Staff',
|
||||
last_name='User',
|
||||
is_staff=True
|
||||
)
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'staff@example.com',
|
||||
'password': 'staffpass'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertIn('user', data)
|
||||
|
||||
def test_session_status_after_explicit_logout(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.client.post('/api/user/logout/')
|
||||
|
||||
response = self.client.get('/api/user/session/')
|
||||
data = response.json()
|
||||
self.assertFalse(data['isAuthenticated'])
|
||||
|
||||
def test_signup_with_null_optional_fields(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'optional@example.com',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'Optional',
|
||||
'last_name': 'Fields'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_change_password_with_missing_confirm_password(self):
|
||||
self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
response = self.client.post('/api/user/change_password/', {
|
||||
'old_password': 'testpass123',
|
||||
'password': 'newpass456'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_login_and_logout_sequence(self):
|
||||
resp1 = self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(resp1.status_code, status.HTTP_200_OK)
|
||||
|
||||
me1 = self.client.get('/api/user/me/')
|
||||
self.assertEqual(me1.status_code, status.HTTP_200_OK)
|
||||
|
||||
logout_resp = self.client.post('/api/user/logout/')
|
||||
self.assertEqual(logout_resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
me2 = self.client.get('/api/user/me/')
|
||||
self.assertEqual(me2.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
resp2 = self.client.post('/api/user/login/', {
|
||||
'email_address': 'edgecase@example.com',
|
||||
'password': 'testpass123'
|
||||
})
|
||||
self.assertEqual(resp2.status_code, status.HTTP_200_OK)
|
||||
|
||||
me3 = self.client.get('/api/user/me/')
|
||||
self.assertEqual(me3.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_invalid_email_format(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'not-an-email',
|
||||
'password': 'newpass123',
|
||||
'confirm_password': 'newpass123',
|
||||
'first_name': 'Invalid',
|
||||
'last_name': 'Email'
|
||||
})
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_201_CREATED])
|
||||
|
||||
def test_empty_password_signup(self):
|
||||
response = self.client.post('/api/user/signup/', {
|
||||
'email_address': 'emptypass@example.com',
|
||||
'password': '',
|
||||
'confirm_password': '',
|
||||
'first_name': 'Empty',
|
||||
'last_name': 'Pass'
|
||||
})
|
||||
self.assertIn(response.status_code, [status.HTTP_400_BAD_REQUEST, status.HTTP_201_CREATED])
|
||||
|
||||
def test_role_preserved_after_login(self):
|
||||
_ = User.objects.create_user(
|
||||
email_address='manager@example.com',
|
||||
password='managerpass',
|
||||
first_name='Manager',
|
||||
last_name='User',
|
||||
role=User.Roles.MANAGER
|
||||
)
|
||||
response = self.client.post('/api/user/login/', {
|
||||
'email_address': 'manager@example.com',
|
||||
'password': 'managerpass'
|
||||
})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
data = response.json()
|
||||
self.assertEqual(data['user']['role'], User.Roles.MANAGER)
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import IntegrityError
|
||||
from django.conf import settings
|
||||
import uuid
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class UserModelTests(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user_data = {
|
||||
'email_address': 'Test@Example.com',
|
||||
'first_name': 'Test',
|
||||
'last_name': 'User',
|
||||
'date_of_birth': '1990-01-01',
|
||||
}
|
||||
|
||||
def test_create_user_and_properties(self):
|
||||
user = User.objects.create_user(password='pass1234', **self.user_data)
|
||||
self.assertIsNotNone(user.pk)
|
||||
self.assertEqual(user.email_address, 'Test@example.com')
|
||||
self.assertEqual(user.full_name, 'Test User')
|
||||
|
||||
def test_create_superuser(self):
|
||||
su = User.objects.create_superuser(password='adminpass', **self.user_data)
|
||||
self.assertTrue(su.is_staff)
|
||||
self.assertIsNotNone(su.pk)
|
||||
self.assertTrue(su.is_active)
|
||||
|
||||
def test_password_hashed_and_check(self):
|
||||
user = User.objects.create_user(email_address='hashme@example.com', password='secret123')
|
||||
self.assertNotEqual(user.password, 'secret123')
|
||||
self.assertTrue(user.check_password('secret123'))
|
||||
|
||||
def test_uuid_and_id_auto_populated(self):
|
||||
u1 = User.objects.create_user(email_address='one@example.com', password='p')
|
||||
u2 = User.objects.create_user(email_address='two@example.com', password='p')
|
||||
self.assertIsNotNone(u1.uuid)
|
||||
self.assertIsInstance(u1.uuid, uuid.UUID)
|
||||
self.assertNotEqual(u1.uuid, u2.uuid)
|
||||
self.assertIsNotNone(u1.id)
|
||||
self.assertIsNotNone(u2.id)
|
||||
|
||||
def test_default_fields(self):
|
||||
u = User.objects.create_user(email_address='defaults@example.com', password='p')
|
||||
self.assertEqual(u.bio, "")
|
||||
self.assertEqual(u.timezone, settings.TIME_ZONE)
|
||||
self.assertEqual(u.avatar_url, "")
|
||||
self.assertTrue(u.is_active)
|
||||
self.assertFalse(u.is_staff)
|
||||
|
||||
def test_unique_email_constraint(self):
|
||||
User.objects.create_user(email_address='dup@example.com', password='p')
|
||||
with self.assertRaises(IntegrityError):
|
||||
User.objects.create_user(email_address='dup@example.com', password='p')
|
||||
|
||||
def test_create_user_without_email_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
User.objects.create_user(email_address='', password='p')
|
||||
|
||||
def test_date_of_birth_optional(self):
|
||||
u = User.objects.create_user(email_address='nodob@example.com', password='p')
|
||||
self.assertIsNone(u.date_of_birth)
|
||||
|
||||
def test_str_and_full_name(self):
|
||||
u = User.objects.create_user(
|
||||
email_address='name@example.com',
|
||||
password='p',
|
||||
first_name='A',
|
||||
last_name='B'
|
||||
)
|
||||
self.assertEqual(u.full_name, 'A B')
|
||||
self.assertEqual(str(u), 'A B')
|
||||
|
||||
def test_email_normalization_domain_lowercase(self):
|
||||
user1 = User.objects.create_user(email_address='Test@EXAMPLE.COM', password='p')
|
||||
self.assertEqual(user1.email_address, 'Test@example.com')
|
||||
user2 = User.objects.create_user(email_address='test@EXAMPLE.COM', password='p2')
|
||||
self.assertEqual(user2.email_address, 'test@example.com')
|
||||
self.assertNotEqual(user1.email_address, user2.email_address)
|
||||
|
||||
def test_superuser_must_have_is_staff(self):
|
||||
with self.assertRaises(ValueError):
|
||||
User.objects.create_superuser(
|
||||
email_address='fail@example.com',
|
||||
password='p',
|
||||
is_staff=False
|
||||
)
|
||||
|
||||
def test_role_default_is_employee(self):
|
||||
u = User.objects.create_user(email_address='role@example.com', password='p')
|
||||
self.assertEqual(u.role, User.Roles.EMPLOYEE)
|
||||
|
||||
def test_role_choices(self):
|
||||
u = User.objects.create_user(
|
||||
email_address='manager@example.com',
|
||||
password='p',
|
||||
role=User.Roles.MANAGER
|
||||
)
|
||||
self.assertEqual(u.role, User.Roles.MANAGER)
|
||||
|
||||
def test_timestamps_auto_set(self):
|
||||
from datetime import timedelta
|
||||
u = User.objects.create_user(email_address='timestamps@example.com', password='p')
|
||||
self.assertIsNotNone(u.created_at)
|
||||
self.assertIsNotNone(u.updated_at)
|
||||
time_diff = abs((u.updated_at - u.created_at).total_seconds())
|
||||
self.assertLess(time_diff, 1.0)
|
||||
|
||||
def test_has_perm_returns_true(self):
|
||||
u = User.objects.create_user(email_address='perm@example.com', password='p')
|
||||
self.assertTrue(u.has_perm('any.permission'))
|
||||
self.assertTrue(u.has_perm('another.permission', obj=None))
|
||||
|
||||
def test_has_module_perms_returns_true(self):
|
||||
u = User.objects.create_user(email_address='modperm@example.com', password='p')
|
||||
self.assertTrue(u.has_module_perms('auth'))
|
||||
self.assertTrue(u.has_module_perms('users'))
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny, IsAuthenticated
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from apps.users.models import User
|
||||
from apps.users.serializers import UserSerializer
|
||||
|
||||
class UserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
lookup_field = 'uuid'
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[AllowAny])
|
||||
def login(self, request):
|
||||
email_address = request.data.get('email_address')
|
||||
password = request.data.get('password')
|
||||
|
||||
if not email_address or not password:
|
||||
return Response(
|
||||
{'error': 'Email and password are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
email_address = User.objects.normalize_email(email_address)
|
||||
user = authenticate(request, username=email_address, password=password)
|
||||
|
||||
if user is None:
|
||||
return Response(
|
||||
{'error': 'Invalid credentials'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
login(request, user)
|
||||
return Response({
|
||||
'user': UserSerializer(user).data,
|
||||
'message': 'Login successful',
|
||||
'success': True
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
|
||||
def logout(self, request):
|
||||
logout(request)
|
||||
return Response(
|
||||
{'message': 'Logout successful', 'success': True},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
def me(self, request):
|
||||
user_data = UserSerializer(request.user).data
|
||||
user_data['success'] = True
|
||||
return Response(user_data)
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[AllowAny])
|
||||
def session(self, request):
|
||||
return Response({
|
||||
'isAuthenticated': request.user.is_authenticated,
|
||||
'isStaff': request.user.is_staff if request.user.is_authenticated else False
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[AllowAny])
|
||||
def signup(self, request):
|
||||
try:
|
||||
data = request.data
|
||||
except:
|
||||
return Response(
|
||||
{'detail': 'Invalid data provided.', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
email_address = data.get('email_address')
|
||||
if not email_address:
|
||||
return Response(
|
||||
{'detail': 'Email address is required.', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Normalize email
|
||||
email_address = User.objects.normalize_email(email_address)
|
||||
|
||||
if User.objects.filter(email_address=email_address).exists():
|
||||
return Response(
|
||||
{'detail': 'Email address already exists.', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not data.get('first_name') or not data.get('last_name'):
|
||||
return Response(
|
||||
{'detail': 'First and last name(s) must be provided.', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.get('password') != data.get('confirm_password'):
|
||||
return Response(
|
||||
{'detail': 'Passwords do not match.', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.create_user(
|
||||
email_address=email_address,
|
||||
password=data.get('password'),
|
||||
first_name=data.get('first_name'),
|
||||
last_name=data.get('last_name'),
|
||||
date_of_birth=data.get('date_of_birth')
|
||||
)
|
||||
return Response(
|
||||
{'detail': 'User account created successfully.', 'success': True},
|
||||
status=status.HTTP_201_CREATED
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'detail': str(e), 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[IsAuthenticated])
|
||||
def change_password(self, request):
|
||||
data = request.data
|
||||
required_fields = ['old_password', 'password', 'confirm_password']
|
||||
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
return Response(
|
||||
{'detail': f'"{field}" not provided', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.get('password') != data.get('confirm_password'):
|
||||
return Response(
|
||||
{'detail': 'Passwords do not match', 'success': False},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
user = request.user
|
||||
if not user.check_password(data.get('old_password')):
|
||||
return Response(
|
||||
{'detail': 'Old password is incorrect', 'success': False},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
user.set_password(data.get('password'))
|
||||
user.save()
|
||||
return Response(
|
||||
{'detail': 'Password changed successfully', 'success': True},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
FROM python:3.12-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV VIRTUAL_ENV=/venv \
|
||||
PATH=/venv/bin:$PATH
|
||||
|
||||
RUN python -m venv /venv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements/base.txt .
|
||||
RUN pip install --no-cache-dir --requirement base.txt
|
||||
|
||||
CMD ["celery", "-A", "config", "worker", "-l", "info"]
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
|
||||
services:
|
||||
|
||||
fyp-postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: ${POSTGRES_CONTAINER_NAME:-fyp-postgres}
|
||||
env_file:
|
||||
- ../../.env
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fyp}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
fyp-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ${REDIS_CONTAINER_NAME:-fyp-redis}
|
||||
ports:
|
||||
- "0.0.0.0:6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compose/dev/node/Dockerfile
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
stdin_open: true
|
||||
ports:
|
||||
- "0.0.0.0:5173:5173"
|
||||
volumes:
|
||||
- ../../src:/app/src:delegated
|
||||
- ../../index.html:/app/index.html:delegated
|
||||
- ../../vite.config.ts:/app/vite.config.ts:delegated
|
||||
- ../../tsconfig.json:/app/tsconfig.json:delegated
|
||||
- ../../build:/app/build:delegated
|
||||
- /app/node_modules
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compose/dev/python/Dockerfile
|
||||
container_name: dynavera-api
|
||||
ports:
|
||||
- "0.0.0.0:8000:8000"
|
||||
volumes:
|
||||
- ../../:/app
|
||||
environment:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
DJANGO_DEBUG: "true"
|
||||
DJANGO_ALLOWED_HOSTS: "*"
|
||||
DJANGO_CELERY_BROKER_URL: redis://${REDIS_CONTAINER_NAME:-fyp-redis}:6379/0
|
||||
DJANGO_CORS_ALLOWED_ORIGINS: http://localhost:5173,http://127.0.0.1:5173
|
||||
DJANGO_SETTINGS_MODULE: config.settings
|
||||
env_file:
|
||||
- ../../.env
|
||||
depends_on:
|
||||
fyp-redis:
|
||||
condition: service_healthy
|
||||
fyp-postgres:
|
||||
condition: service_healthy
|
||||
web:
|
||||
condition: service_started
|
||||
mcp-agent-server:
|
||||
condition: service_started
|
||||
|
||||
celery:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compose/dev/celery/Dockerfile
|
||||
container_name: dynavera-celery
|
||||
volumes:
|
||||
- ../../:/app
|
||||
- ${USERPROFILE}/.cache/gpt4all:/root/.cache/gpt4all:rw
|
||||
environment:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
DJANGO_CELERY_BROKER_URL: redis://${REDIS_CONTAINER_NAME:-fyp-redis}:6379/0
|
||||
DJANGO_SETTINGS_MODULE: config.settings
|
||||
env_file:
|
||||
- ../../.env
|
||||
depends_on:
|
||||
fyp-redis:
|
||||
condition: service_healthy
|
||||
fyp-postgres:
|
||||
condition: service_healthy
|
||||
mcp-agent-server:
|
||||
condition: service_started
|
||||
|
||||
mcp-agent-server:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compose/dev/mcp/Dockerfile
|
||||
container_name: dynavera-mcp-agent
|
||||
ports:
|
||||
- "0.0.0.0:8001:8001"
|
||||
volumes:
|
||||
- ../../:/app
|
||||
- ${USERPROFILE}/.cache/gpt4all:/root/.cache/gpt4all:rw
|
||||
- ../../build/rag_db:/app/build/rag_db:ro
|
||||
environment:
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-secret-key-change-in-production}
|
||||
DJANGO_SETTINGS_MODULE: config.settings
|
||||
PYTHONUNBUFFERED: "1"
|
||||
HOME: /root
|
||||
env_file:
|
||||
- ../../.env
|
||||
depends_on:
|
||||
fyp-redis:
|
||||
condition: service_healthy
|
||||
fyp-postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
venv:
|
||||
postgres_data:
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
FROM nvidia/cuda:11.8.0-runtime-ubuntu22.04
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
build-essential \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -sf /usr/bin/python3 /usr/bin/python \
|
||||
&& ln -sf /usr/bin/pip3 /usr/bin/pip
|
||||
|
||||
COPY requirements/base.txt requirements/base.txt
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
python3-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
cmake \
|
||||
pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements/base.txt && \
|
||||
pip install --no-cache-dir mcp gpt4all sentence-transformers chromadb
|
||||
|
||||
RUN if [ ! -e /usr/lib/x86_64-linux-gnu/libcudart.so.11.0 ]; then \
|
||||
found=$(ls /usr/local/cuda/lib64/libcudart.so* 2>/dev/null | head -n1 || true); \
|
||||
if [ -n "$found" ]; then \
|
||||
mkdir -p /usr/lib/x86_64-linux-gnu || true; \
|
||||
ln -sf "$found" /usr/lib/x86_64-linux-gnu/libcudart.so.11.0 || true; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
COPY apps /app/apps
|
||||
COPY config /app/config
|
||||
COPY mcp_agent /app/mcp_agent
|
||||
COPY manage.py /app/
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["python", "-m", "mcp_agent.mcp_server"]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
FROM node:22-bullseye
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci && npm cache clean --force
|
||||
|
||||
COPY src ./src
|
||||
COPY index.html .
|
||||
COPY vite.config.* .
|
||||
COPY tsconfig.* .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["sh", "-c", "npm run dev -- --host 0.0.0.0 & npm run build -- --watch"]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
FROM python:3.12-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
wait-for-it \
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV VIRTUAL_ENV=/venv \
|
||||
PATH=/venv/bin:$PATH
|
||||
|
||||
RUN python -m venv /venv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements/base.txt .
|
||||
RUN pip install --no-cache-dir --requirement base.txt
|
||||
|
||||
COPY ./compose/prod/start /start
|
||||
RUN sed -i 's/\r$//g' /start && chmod +x /start
|
||||
|
||||
CMD ["/start"]
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
FROM python:3.12.0-slim
|
||||
|
||||
LABEL org.opencontainers.image.title="Dynavera Celery Worker"
|
||||
LABEL org.opencontainers.image.source="https://git.cs.bham.ac.uk/projects-2025-26/vxn217"
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements/* .
|
||||
RUN pip install --no-cache-dir -r prod.txt
|
||||
|
||||
COPY manage.py manage.py
|
||||
COPY config config
|
||||
COPY apps apps
|
||||
COPY data data
|
||||
|
||||
RUN mkdir -p /app/static
|
||||
|
||||
CMD ["celery", "-A", "config.celery", "worker", "--loglevel=info"]
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
services:
|
||||
fyp-traefik:
|
||||
image: traefik:v2.10
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.mcp.address=:${MCP_PORT:-58001}"
|
||||
ports:
|
||||
- "${MCP_PORT:-58001}:${MCP_PORT:-58001}"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
networks:
|
||||
- mcp-internal
|
||||
|
||||
fyp-mcp:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compose/dev/mcp/Dockerfile
|
||||
container_name: dynavera-mcp-server
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 1
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
env_file:
|
||||
- ../../.env
|
||||
environment:
|
||||
- MCP_HTTP_HOST=0.0.0.0
|
||||
- MCP_HTTP_PORT=8001
|
||||
- NVIDIA_VISIBLE_DEVICES=all
|
||||
command: python -m mcp_agent.mcp_server
|
||||
volumes:
|
||||
- ../../:/app
|
||||
- ${USERPROFILE}/.cache/gpt4all:/root/.cache/gpt4all:rw
|
||||
- ../../build/rag_db:/app/build/rag_db
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.fyp-mcp.rule=Host(`${MCP_DOMAIN}`)"
|
||||
- "traefik.http.routers.fyp-mcp.entrypoints=mcp"
|
||||
- "traefik.http.services.fyp-mcp.loadbalancer.server.port=8001"
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
- "com.centurylinklabs.watchtower.scope=fyp"
|
||||
networks:
|
||||
- mcp-internal
|
||||
|
||||
networks:
|
||||
mcp-internal:
|
||||
driver: bridge
|
||||
|
||||
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
|
||||
|
||||
services:
|
||||
|
||||
fyp-postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: fyp-postgres
|
||||
hostname: fyp-postgres
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../../.env
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
fyp-web:
|
||||
image: ${IMAGE}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: ${REPLICAS}
|
||||
env_file:
|
||||
- ../../.env
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.fyp-web.rule=Host(`${DOMAIN}`)"
|
||||
- "traefik.http.routers.fyp-web.entrypoints=${ENTRYPOINT}"
|
||||
- "traefik.http.routers.fyp-web.tls.certresolver=${CERTRESOLVER}"
|
||||
- "traefik.http.services.fyp-web.loadbalancer.server.port=${PORT}"
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
- "com.centurylinklabs.watchtower.scope=fyp"
|
||||
volumes:
|
||||
- ../../static:/app/static
|
||||
- ../../media:/app/media
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
fyp-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: fyp-redis
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
fyp-celery:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: compose/prod/celery/Dockerfile
|
||||
image: ${CELERY_IMAGE:-fyp-celery:latest}
|
||||
container_name: fyp-celery
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ../../.env
|
||||
depends_on:
|
||||
- fyp-redis
|
||||
- fyp-postgres
|
||||
networks:
|
||||
- proxy
|
||||
volumes:
|
||||
- ../../:/app
|
||||
- ../../static:/app/static
|
||||
- ../../media:/app/media
|
||||
|
||||
fyp-watchtower:
|
||||
image: containrrr/watchtower
|
||||
command:
|
||||
- "--scope=fyp"
|
||||
- "--label-enable"
|
||||
- "--interval"
|
||||
- "30"
|
||||
- "--rolling-restart"
|
||||
environment:
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
- REPO_USER=${GITLAB_USER}
|
||||
- REPO_PASS=${GITLAB_PASS}
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
|
||||
fyp-runner:
|
||||
image: gitlab/gitlab-runner:${GITLAB_RUNNER_IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- CI_SERVER_URL=${GITLAB_SERVER_URL}
|
||||
- REGISTRATION_TOKEN=${GITLAB_RUNNER_REGISTRATION_TOKEN}
|
||||
- RUNNER_EXECUTOR=docker
|
||||
- RUNNER_RUN_UNTAGGED=true
|
||||
- RUNNER_TAG_LIST=
|
||||
- DOCKER_TLS_CERTDIR=
|
||||
- DOCKER_IMAGE=${GITLAB_RUNNER_DOCKER_IMAGE}
|
||||
volumes:
|
||||
- gitlab-runner-config:/etc/gitlab-runner
|
||||
- gitlab-machine-config:/root/.docker/machine
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command:
|
||||
- run
|
||||
- "--working-directory=/home/gitlab-runner"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitlab-runner-config:
|
||||
name: gitlab-runner-config
|
||||
gitlab-machine-config:
|
||||
name: gitlab-machine-config
|
||||
postgres_data:
|
||||
name: fyp_postgres_data
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
FROM node:22-alpine AS node
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY vite.config.ts .
|
||||
COPY tsconfig.json .
|
||||
COPY package*.json .
|
||||
COPY src ./src
|
||||
COPY index.html .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM python:3.12.0-slim AS python
|
||||
|
||||
LABEL org.opencontainers.image.title="Dynavera - An Agentic Approach to Domain-Specific Trainers"
|
||||
LABEL org.opencontainers.image.source="https://git.cs.bham.ac.uk/projects-2025-26/vxn217"
|
||||
LABEL org.opencontainers.image.description="Dynavera (Final Year Project)"
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
wait-for-it \
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements/* .
|
||||
RUN pip install --no-cache-dir -r prod.txt
|
||||
|
||||
COPY manage.py manage.py
|
||||
COPY config config
|
||||
COPY apps apps
|
||||
COPY data data
|
||||
|
||||
COPY --from=node /app/build ./build
|
||||
|
||||
RUN mkdir -p /app/static
|
||||
|
||||
COPY ./compose/prod/start /start
|
||||
RUN sed -i 's/\r$//g' /start && chmod +x /start
|
||||
|
||||
ENTRYPOINT ["/start"]
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
DB_HOST="${POSTGRES_HOST:-localhost}"
|
||||
DB_PORT="${POSTGRES_PORT:-5432}"
|
||||
|
||||
echo "Waiting for database at ${DB_HOST}:${DB_PORT}..."
|
||||
wait-for-it ${DB_HOST}:${DB_PORT} --timeout=30 --strict || {
|
||||
echo "Timed out waiting for database" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Database is available, continuing startup..."
|
||||
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate --noinput
|
||||
|
||||
for fixture in /app/data/site/*.json; do
|
||||
echo "Loading fixture: $fixture"
|
||||
python manage.py loaddata "$fixture"
|
||||
done
|
||||
|
||||
python manage.py collectstatic --noinput
|
||||
exec daphne -b 0.0.0.0 -p 8000 config.asgi:application
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,16 +0,0 @@
|
|||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.domains.viewsets import DomainViewSet, OrganizationViewSet, DatasetViewSet, InviteViewSet
|
||||
from apps.users.viewsets import UserViewSet
|
||||
from apps.agents.viewsets import AgentViewSet, AgentExecutionViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'organization', OrganizationViewSet, basename='organization')
|
||||
router.register(r'invite', InviteViewSet, basename='invite')
|
||||
router.register(r'domain', DomainViewSet, basename='domain')
|
||||
router.register(r'dataset', DatasetViewSet, basename='dataset')
|
||||
router.register(r'user', UserViewSet, basename='user')
|
||||
router.register(r'agent', AgentViewSet, basename='agent')
|
||||
router.register(r'agent-execution', AgentExecutionViewSet, basename='agent-execution')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import os
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.security.websocket import AllowedHostsOriginValidator
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from apps.agents.routing import websocket_urlpatterns
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": django_asgi_app,
|
||||
"websocket": AllowedHostsOriginValidator(
|
||||
AuthMiddlewareStack(
|
||||
URLRouter(websocket_urlpatterns)
|
||||
)
|
||||
),
|
||||
})
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from celery import Celery
|
||||
import os
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
app = Celery('config')
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
app.autodiscover_tasks()
|
||||
|
|
@ -1,207 +0,0 @@
|
|||
from dotenv import load_dotenv
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
load_dotenv(dotenv_path = BASE_DIR / '.env')
|
||||
|
||||
BUILD_DIR = os.getenv('DJANGO_BUILD_DIR', BASE_DIR / 'build')
|
||||
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
|
||||
DEBUG = str(os.getenv('DJANGO_DEBUG')).lower() in ('1', 'true', 'yes', 'on')
|
||||
DOMAIN_NAME = os.getenv('DOMAIN_NAME', 'localhost')
|
||||
|
||||
ALLOWED_HOSTS = [stripped_host for host in os.getenv('DJANGO_ALLOWED_HOSTS', 'localhost').split(',') if (stripped_host:=host.strip())]
|
||||
|
||||
PARENT_NAME = Path(__file__).resolve().parent.name
|
||||
|
||||
STATIC_URL = os.getenv('DJANGO_STATIC_URL', '/static/')
|
||||
MEDIA_URL = os.getenv('DJANGO_MEDIA_URL', '/media/')
|
||||
STATIC_ROOT = os.getenv('DJANGO_STATIC_ROOT', BASE_DIR / 'static')
|
||||
MEDIA_ROOT = os.getenv('DJANGO_MEDIA_ROOT', BASE_DIR / 'media')
|
||||
|
||||
OVERRIDE_APPS = [
|
||||
'jazzmin',
|
||||
'daphne',
|
||||
]
|
||||
DJANGO_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
]
|
||||
THIRD_PARTY_APPS = [
|
||||
'rest_framework',
|
||||
'channels',
|
||||
'django_celery_results',
|
||||
'django_celery_beat',
|
||||
'corsheaders',
|
||||
]
|
||||
LOCAL_APPS = [
|
||||
'apps.users',
|
||||
'apps.domains',
|
||||
'apps.agents',
|
||||
]
|
||||
INSTALLED_APPS = OVERRIDE_APPS + DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'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',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = f'{PARENT_NAME}.urls'
|
||||
WSGI_APPLICATION = f'{PARENT_NAME}.wsgi.application'
|
||||
ASGI_APPLICATION = f'{PARENT_NAME}.asgi.application'
|
||||
|
||||
DJANGO_CELERY_BROKER_URL = os.getenv('DJANGO_CELERY_BROKER_URL', 'redis://localhost:6379/0')
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
'default': {
|
||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
||||
'CONFIG': {
|
||||
'hosts': [DJANGO_CELERY_BROKER_URL],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
DB_ENGINE = os.getenv('DJANGO_DB_ENGINE', 'django.db.backends.sqlite3')
|
||||
DB_NAME = os.getenv('POSTGRES_DB', BASE_DIR / 'db.sqlite3')
|
||||
DB_USER = os.getenv('POSTGRES_USER')
|
||||
DB_PASSWORD = os.getenv('POSTGRES_PASSWORD')
|
||||
DB_HOST = os.getenv('POSTGRES_HOST')
|
||||
DB_PORT = os.getenv('POSTGRES_PORT', 5432)
|
||||
|
||||
if any(arg.startswith('test') for arg in sys.argv):
|
||||
DB_ENGINE = 'django.db.backends.sqlite3'
|
||||
DB_NAME = ':memory:'
|
||||
DB_USER = None
|
||||
DB_PASSWORD = None
|
||||
DB_HOST = None
|
||||
DB_PORT = None
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': DB_ENGINE,
|
||||
'NAME': DB_NAME,
|
||||
'USER': DB_USER,
|
||||
'PASSWORD': DB_PASSWORD,
|
||||
'HOST': DB_HOST,
|
||||
'PORT': DB_PORT,
|
||||
'CONN_MAX_AGE': 600,
|
||||
}
|
||||
}
|
||||
|
||||
if DB_ENGINE == 'django.db.backends.sqlite3':
|
||||
DATABASES['default'] = {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': DB_NAME,
|
||||
}
|
||||
|
||||
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en-uk'
|
||||
TIME_ZONE = 'UTC'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.AllowAny',
|
||||
],
|
||||
}
|
||||
|
||||
CELERY_BROKER_URL = DJANGO_CELERY_BROKER_URL
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_CACHE_BACKEND = 'django-cache'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = 'UTC'
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 30 * 60
|
||||
|
||||
MCP_AGENT_URL = os.getenv('MCP_AGENT_URL')
|
||||
|
||||
X_FRAME_OPTIONS = 'SAMEORIGIN'
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOWED_ORIGINS = [
|
||||
f'http://{DOMAIN_NAME}',
|
||||
f'https://{DOMAIN_NAME}',
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f'http://{DOMAIN_NAME}',
|
||||
f'https://{DOMAIN_NAME}',
|
||||
]
|
||||
|
||||
|
||||
CSRF_COOKIE_HTTPONLY = False
|
||||
CSRF_COOKIE_SECURE = not DEBUG
|
||||
CSRF_COOKIE_SAMESITE = 'Lax'
|
||||
|
||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = not DEBUG
|
||||
SESSION_COOKIE_AGE = 1209600
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
|
||||
if DEBUG:
|
||||
CORS_ALLOWED_ORIGINS.append(f'http://{DOMAIN_NAME}:5173')
|
||||
CORS_ALLOWED_ORIGINS.append(f'http://{DOMAIN_NAME}:8000')
|
||||
CSRF_TRUSTED_ORIGINS.append(f'http://{DOMAIN_NAME}:5173')
|
||||
CSRF_TRUSTED_ORIGINS.append(f'http://{DOMAIN_NAME}:8000')
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import path, include, re_path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
||||
from .views import serve_frontend
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('config.api')),
|
||||
re_path(r'^(?!static/|media/)(?P<path>.*)$', serve_frontend, {'document_root': settings.BUILD_DIR}),
|
||||
*static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
|
||||
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
|
||||
]
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
from django.utils._os import safe_join
|
||||
from django.views.static import serve as static_serve
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
import posixpath
|
||||
from pathlib import Path
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def serve_frontend(request, path, document_root = None):
|
||||
path = posixpath.normpath(path).lstrip("/")
|
||||
fullpath = Path(safe_join(document_root, path))
|
||||
if fullpath.is_file():
|
||||
return static_serve(request, path, document_root)
|
||||
else:
|
||||
return static_serve(request, "index.html", document_root)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
application = get_wsgi_application()
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
[
|
||||
{
|
||||
"model": "users.user",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"password": "pbkdf2_sha256$1000000$3z9wkVXkAJggKwrXvhUJJc$swijjGwX6JsYjRvuNECvPxqG8BqMydUjaz1FemMWVL8=",
|
||||
"last_login": null,
|
||||
"is_superuser": false,
|
||||
"created_at": "2025-12-18T23:51:25.301Z",
|
||||
"updated_at": "2025-12-18T23:51:25.301Z",
|
||||
"uuid": "5cbef8ca-a24d-4f88-b403-0d53f6a239e6",
|
||||
"email_address": "a@gmail.com",
|
||||
"first_name": "a",
|
||||
"last_name": "a",
|
||||
"date_of_birth": "2001-01-01",
|
||||
"bio": "",
|
||||
"timezone": "UTC",
|
||||
"avatar_url": "",
|
||||
"is_active": true,
|
||||
"is_staff": true,
|
||||
"role": "manager",
|
||||
"groups": [],
|
||||
"user_permissions": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
[
|
||||
{
|
||||
"model": "agents.agent",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"uuid": "70ca3755-6a98-4914-9a01-75e8ee7ffd77",
|
||||
"user": 1,
|
||||
"name": "fnirs",
|
||||
"description": "fNIRS acquisition agent",
|
||||
"status": "idle",
|
||||
"task_id": null,
|
||||
"created_at": "2025-12-21T11:20:25.792Z",
|
||||
"updated_at": "2025-12-21T11:20:25.792Z",
|
||||
"started_at": null,
|
||||
"completed_at": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "agents.agent",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"uuid": "370969d9-dc95-4410-9299-b3d8bc05beec",
|
||||
"user": 1,
|
||||
"name": "eeg",
|
||||
"description": "EEG acquisition agent",
|
||||
"status": "idle",
|
||||
"task_id": null,
|
||||
"created_at": "2025-12-21T11:20:25.795Z",
|
||||
"updated_at": "2025-12-21T11:20:25.795Z",
|
||||
"started_at": null,
|
||||
"completed_at": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "agents.agent",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"uuid": "2654ce7a-4374-4a9a-8361-cba104b5970d",
|
||||
"user": 1,
|
||||
"name": "simulator",
|
||||
"description": "Simulation / test agent",
|
||||
"status": "idle",
|
||||
"task_id": null,
|
||||
"created_at": "2025-12-21T11:20:25.797Z",
|
||||
"updated_at": "2025-12-21T11:20:25.797Z",
|
||||
"started_at": null,
|
||||
"completed_at": null
|
||||
}
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
|
@ -1,56 +0,0 @@
|
|||
import vue from 'eslint-plugin-vue';
|
||||
import nx from '@nx/eslint-plugin';
|
||||
|
||||
export default [
|
||||
...vue.configs['flat/recommended'],
|
||||
...nx.configs['flat/base'],
|
||||
...nx.configs['flat/typescript'],
|
||||
...nx.configs['flat/javascript'],
|
||||
{
|
||||
ignores: [
|
||||
'**/dist',
|
||||
'**/build',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'**/*.ts',
|
||||
'**/*.tsx',
|
||||
'**/*.cts',
|
||||
'**/*.mts',
|
||||
'**/*.js',
|
||||
'**/*.jsx',
|
||||
'**/*.cjs',
|
||||
'**/*.mjs',
|
||||
'**/*.vue'
|
||||
],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'@nx/enforce-module-boundaries': [
|
||||
'error',
|
||||
{
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: ['^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$'],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: '*',
|
||||
onlyDependOnLibsWithTags: ['*']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
16
index.html
16
index.html
|
|
@ -1,16 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Dynavera</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
22
manage.py
22
manage.py
|
|
@ -1,22 +0,0 @@
|
|||
#!/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', 'config.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 and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.conf import settings
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPAgentClient:
|
||||
|
||||
def __init__(self, server_url: Optional[str] = None):
|
||||
self.server_url = server_url or getattr(settings, 'MCP_AGENT_URL')
|
||||
self.http_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(300.0),
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.http_client:
|
||||
await self.http_client.aclose()
|
||||
|
||||
async def execute_agent(
|
||||
self,
|
||||
agent_id: str,
|
||||
agent_name: str,
|
||||
execution_id: str,
|
||||
query: str,
|
||||
input_data: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
arguments = {
|
||||
"agent_id": agent_id,
|
||||
"agent_name": agent_name,
|
||||
"execution_id": execution_id,
|
||||
"query": query,
|
||||
"input_data": input_data or {}
|
||||
}
|
||||
|
||||
return await self._execute_via_http(arguments)
|
||||
|
||||
async def _execute_via_http(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not self.http_client:
|
||||
raise RuntimeError("HTTP client not initialized")
|
||||
|
||||
try:
|
||||
response = await self.http_client.post(
|
||||
f"{self.server_url}/execute",
|
||||
json={
|
||||
"tool": "execute_agent",
|
||||
"arguments": arguments
|
||||
},
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error from MCP server: {e.response.status_code} - {e.response.text}")
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"Server returned {e.response.status_code}",
|
||||
"error_type": "HTTPError",
|
||||
"details": e.response.text
|
||||
}
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error to MCP server: {e}")
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": f"Failed to connect to MCP server at {self.server_url}",
|
||||
"error_type": "ConnectionError"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in HTTP execution: {e}")
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__
|
||||
}
|
||||
|
||||
async def health_check(self) -> Dict[str, Any]:
|
||||
try:
|
||||
response = await self.http_client.get(f"{self.server_url}/health")
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
return {"status": "unhealthy", "error": str(e)}
|
||||
|
||||
async def list_tools(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "execute_agent",
|
||||
"description": "Execute an AI agent with given query and input data"
|
||||
},
|
||||
{
|
||||
"name": "health_check",
|
||||
"description": "Check if the agent server is healthy"
|
||||
}
|
||||
]
|
||||
|
||||
async def close(self):
|
||||
if self.http_client:
|
||||
await self.http_client.aclose()
|
||||
|
||||
_mcp_client_instance: Optional[MCPAgentClient] = None
|
||||
_client_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def get_mcp_client() -> MCPAgentClient:
|
||||
global _mcp_client_instance
|
||||
|
||||
async with _client_lock:
|
||||
if _mcp_client_instance is None:
|
||||
server_url = getattr(settings, 'MCP_AGENT_URL')
|
||||
_mcp_client_instance = MCPAgentClient(server_url=server_url)
|
||||
|
||||
return _mcp_client_instance
|
||||
|
||||
|
||||
async def close_mcp_client():
|
||||
global _mcp_client_instance
|
||||
|
||||
async with _client_lock:
|
||||
if _mcp_client_instance is not None:
|
||||
await _mcp_client_instance.close()
|
||||
_mcp_client_instance = None
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
if __name__ == "__main__":
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
import django
|
||||
django.setup()
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
|
||||
from django.utils import timezone
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
app = Server("dynavera-agent-runtime")
|
||||
|
||||
|
||||
async def handle_health(request: web.Request) -> web.Response:
|
||||
try:
|
||||
result = await check_health()
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "unhealthy", "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_execute(request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload = await request.json()
|
||||
tool = payload.get("tool")
|
||||
arguments = payload.get("arguments", {}) or {}
|
||||
if tool not in {"execute_agent", "health_check"}:
|
||||
return web.json_response({"status": "failed", "error": f"Unknown tool: {tool}"}, status=400)
|
||||
|
||||
if tool == "execute_agent":
|
||||
result = await run_agent_execution(arguments)
|
||||
else:
|
||||
result = await check_health()
|
||||
|
||||
return web.json_response(result)
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"status": "failed", "error": "Invalid JSON payload"}, status=400)
|
||||
except Exception as e:
|
||||
print(f"[MCP Server] HTTP execute error: {e}")
|
||||
return web.json_response({"status": "failed", "error": str(e)}, status=500)
|
||||
|
||||
|
||||
@app.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name="execute_agent",
|
||||
description="Execute an AI agent with given query and input data. Supports RAG-enabled responses using local knowledge base.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "UUID of the agent to execute"
|
||||
},
|
||||
"agent_name": {
|
||||
"type": "string",
|
||||
"description": "Name of the agent"
|
||||
},
|
||||
"execution_id": {
|
||||
"type": "string",
|
||||
"description": "UUID of the execution record"
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "User query to process"
|
||||
},
|
||||
"input_data": {
|
||||
"type": "object",
|
||||
"description": "Additional input parameters"
|
||||
}
|
||||
},
|
||||
"required": ["agent_id", "agent_name", "execution_id", "query"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="health_check",
|
||||
description="Check if the agent server is healthy and ready to process requests",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@app.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent | ImageContent | EmbeddedResource]:
|
||||
if name == "execute_agent":
|
||||
result = await run_agent_execution(arguments)
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(result, indent=2)
|
||||
)]
|
||||
|
||||
elif name == "health_check":
|
||||
health_info = await check_health()
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=json.dumps(health_info, indent=2)
|
||||
)]
|
||||
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
async def check_health() -> Dict[str, Any]:
|
||||
import platform
|
||||
|
||||
MODEL_NAME = "Meta-Llama-3-8B-Instruct.Q4_0.gguf"
|
||||
DEFAULT_MODEL_DIR = os.path.join(os.path.expanduser("~"), ".cache", "gpt4all")
|
||||
MODEL_PATH = os.path.join(DEFAULT_MODEL_DIR, MODEL_NAME)
|
||||
RAG_PATH = "./build/rag_db"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"platform": platform.platform(),
|
||||
"python_version": platform.python_version(),
|
||||
"model_available": os.path.exists(MODEL_PATH),
|
||||
"model_path": MODEL_PATH,
|
||||
"rag_available": os.path.exists(RAG_PATH),
|
||||
"rag_path": RAG_PATH
|
||||
}
|
||||
|
||||
|
||||
async def run_agent_execution(arguments: dict) -> Dict[str, Any]:
|
||||
agent_id = arguments["agent_id"]
|
||||
agent_name = arguments["agent_name"]
|
||||
execution_id = arguments["execution_id"]
|
||||
query = arguments.get("query", "")
|
||||
input_data = arguments.get("input_data", {})
|
||||
|
||||
print(f"[MCP Server] Executing agent {agent_name} (ID: {agent_id})")
|
||||
print(f"[MCP Server] Execution ID: {execution_id}")
|
||||
print(f"[MCP Server] Query: {query}")
|
||||
|
||||
if not query:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "No query provided",
|
||||
"execution_id": execution_id,
|
||||
"timestamp": timezone.now().isoformat()
|
||||
}
|
||||
|
||||
try:
|
||||
from gpt4all import GPT4All
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from chromadb import PersistentClient
|
||||
|
||||
MODEL_NAME = "Meta-Llama-3-8B-Instruct.Q4_0.gguf"
|
||||
EMBEDDER_NAME = "all-MiniLM-L6-v2"
|
||||
RAG_PATH = "./build/rag_db"
|
||||
CONTEXT_SIZE = 8192
|
||||
DEFAULT_MODEL_DIR = os.path.join(os.path.expanduser("~"), ".cache", "gpt4all")
|
||||
MODEL_PATH = os.path.join(DEFAULT_MODEL_DIR, MODEL_NAME)
|
||||
|
||||
print(f"[MCP Server] MODEL_PATH={MODEL_PATH}")
|
||||
|
||||
# Check if model exists, fail if not
|
||||
if not os.path.exists(MODEL_PATH):
|
||||
error_msg = f"Model not found at {MODEL_PATH}"
|
||||
print(f"[MCP Server] {error_msg}")
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"error_type": "ModelNotFound",
|
||||
"execution_id": execution_id,
|
||||
"timestamp": timezone.now().isoformat()
|
||||
}
|
||||
|
||||
print("[MCP Server] Full pipeline - loading models")
|
||||
events = []
|
||||
|
||||
# Initialize AI model
|
||||
events.append({
|
||||
"type": "progress",
|
||||
"stage": "initializing",
|
||||
"message": "Initializing AI model...",
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
|
||||
# RAG retrieval if available
|
||||
if os.path.exists(RAG_PATH):
|
||||
print(f"[MCP Server] RAG path found at {RAG_PATH}")
|
||||
try:
|
||||
embedder = SentenceTransformer(EMBEDDER_NAME)
|
||||
client = PersistentClient(path=RAG_PATH)
|
||||
collection = client.get_collection("documents")
|
||||
|
||||
events.append({
|
||||
"type": "progress",
|
||||
"stage": "retrieval",
|
||||
"message": "Retrieving relevant context...",
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
|
||||
query_embedding = embedder.encode(query).tolist()
|
||||
results = collection.query(query_embeddings=[query_embedding], n_results=3)
|
||||
|
||||
retrieved_docs = []
|
||||
if results and results.get('documents'):
|
||||
retrieved_docs = results['documents'][0]
|
||||
|
||||
context = "\n\n".join(retrieved_docs) if retrieved_docs else ""
|
||||
|
||||
events.append({
|
||||
"type": "progress",
|
||||
"stage": "retrieved",
|
||||
"message": f"Retrieved {len(retrieved_docs)} relevant documents",
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as rag_error:
|
||||
print(f"[MCP Server] RAG error: {rag_error}")
|
||||
context = ""
|
||||
events.append({
|
||||
"type": "warning",
|
||||
"message": f"RAG retrieval failed: {str(rag_error)}",
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
else:
|
||||
context = ""
|
||||
|
||||
# Load and run LLM
|
||||
events.append({
|
||||
"type": "progress",
|
||||
"stage": "generating",
|
||||
"message": "Generating response...",
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
|
||||
model = GPT4All(MODEL_NAME, model_path=DEFAULT_MODEL_DIR, allow_download=False)
|
||||
|
||||
if context:
|
||||
prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:"
|
||||
else:
|
||||
prompt = f"Question: {query}\n\nAnswer:"
|
||||
|
||||
print(f"[MCP Server] Running model inference...")
|
||||
response = model.generate(prompt, max_tokens=512, temp=0.7)
|
||||
|
||||
print(f"[MCP Server] Generated response: {response[:100]}...")
|
||||
|
||||
events.append({
|
||||
"type": "progress",
|
||||
"stage": "completed",
|
||||
"message": "Response generated successfully",
|
||||
"timestamp": timezone.now().isoformat()
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "completed",
|
||||
"query": query,
|
||||
"response": response,
|
||||
"method": "rag" if context else "direct",
|
||||
"context_used": bool(context),
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"agent_name": agent_name,
|
||||
"execution_id": execution_id,
|
||||
"events": events
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[MCP Server] Error during execution: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"execution_id": execution_id,
|
||||
"timestamp": timezone.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def run_http_server():
|
||||
host = os.getenv("MCP_HTTP_HOST", "0.0.0.0")
|
||||
port = int(os.getenv("MCP_HTTP_PORT", "8001"))
|
||||
app_http = web.Application()
|
||||
app_http.router.add_post("/execute", handle_execute)
|
||||
app_http.router.add_get("/health", handle_health)
|
||||
|
||||
runner = web.AppRunner(app_http)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host=host, port=port)
|
||||
await site.start()
|
||||
print(f"[MCP Server] HTTP server listening on {host}:{port}", file=sys.stderr)
|
||||
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
async def main():
|
||||
await run_http_server()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
1
notebooks/.gitignore
vendored
1
notebooks/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
|||
langchain_db/
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -1,643 +0,0 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1382faeb",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Fine-tuning a Local LLM Model\n",
|
||||
"Fine-tuning a GPT4All model using fNIRS glossary document data for domain-specific knowledge"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2b910c75",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Import Required Libraries"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "fc6c19b3",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"c:\\Users\\nalab\\University\\vxn217\\.venv\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
|
||||
" from .autonotebook import tqdm as notebook_tqdm\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from gpt4all import GPT4All\n",
|
||||
"from sentence_transformers import SentenceTransformer\n",
|
||||
"from docx import Document\n",
|
||||
"import json\n",
|
||||
"import os\n",
|
||||
"from pathlib import Path\n",
|
||||
"import re\n",
|
||||
"from datetime import datetime"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "86764de4",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Load and Prepare Training Data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "b5393670",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Total raw content length: 67063 characters\n",
|
||||
"Document preview:\n",
|
||||
"fNIRS GLOSSARY PROJECT\n",
|
||||
"LIST OF TERMS\n",
|
||||
"Topic: Hardware\n",
|
||||
"LETTERS A - Z \n",
|
||||
"CHAIR: Samuel Montero-Hernandez (s.monterohdz@gmail.com)\n",
|
||||
"Please read the landing page with instructions first before you move onto editing this document!\n",
|
||||
"\tLINK: fNIRS_Glossary_LandingPage \n",
|
||||
"Template (empty copy that can be copied below as needed).\n",
|
||||
"IMPORTANT NOTE: Please maintain this formatting, including the heading style, labels, and any tags used on the terms. \n",
|
||||
"[Term] (Format: font 12, Arial, bold)\n",
|
||||
"Definition: (Format: font s...\n",
|
||||
"\n",
|
||||
"Total chunks created: 168\n",
|
||||
"Average chunk size: 498 characters\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"DOCS_PATH = \"./documents/fNIRS_Glossary_Hardware.docx\"\n",
|
||||
"\n",
|
||||
"doc = Document(DOCS_PATH)\n",
|
||||
"raw_content = \"\\n\".join([paragraph.text for paragraph in doc.paragraphs if paragraph.text.strip()])\n",
|
||||
"\n",
|
||||
"print(f\"Total raw content length: {len(raw_content)} characters\")\n",
|
||||
"print(f\"Document preview:\\n{raw_content[:500]}...\")\n",
|
||||
"\n",
|
||||
"chunk_size = 500\n",
|
||||
"overlap = 100\n",
|
||||
"chunks = []\n",
|
||||
"for i in range(0, len(raw_content), chunk_size - overlap):\n",
|
||||
" chunk = raw_content[i:i + chunk_size]\n",
|
||||
" if chunk.strip():\n",
|
||||
" chunks.append(chunk.strip())\n",
|
||||
"\n",
|
||||
"print(f\"\\nTotal chunks created: {len(chunks)}\")\n",
|
||||
"print(f\"Average chunk size: {sum(len(c) for c in chunks) // len(chunks)} characters\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "7931fdef",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Configure Model and Training Parameters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "969e4fa4",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Base Model: Meta-Llama-3-8B-Instruct.Q4_0.gguf\n",
|
||||
"Context Size: 8192\n",
|
||||
"Learning Rate: 0.0001\n",
|
||||
"Batch Size: 4\n",
|
||||
"Epochs: 3\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"BASE_MODEL = \"Meta-Llama-3-8B-Instruct.Q4_0.gguf\"\n",
|
||||
"CONTEXT_SIZE = 8192\n",
|
||||
"EMBEDDER_MODEL = \"all-MiniLM-L6-v2\"\n",
|
||||
"\n",
|
||||
"LEARNING_RATE = 0.0001\n",
|
||||
"BATCH_SIZE = 4\n",
|
||||
"NUM_EPOCHS = 3\n",
|
||||
"MAX_TOKENS_PER_SEQUENCE = 2048\n",
|
||||
"\n",
|
||||
"FINE_TUNED_MODEL_PATH = \"./build/fine_tuned_model\"\n",
|
||||
"TRAINING_CONFIG_PATH = \"./build/training_config.json\"\n",
|
||||
"\n",
|
||||
"os.makedirs(FINE_TUNED_MODEL_PATH, exist_ok=True)\n",
|
||||
"os.makedirs(\"./build\", exist_ok=True)\n",
|
||||
"\n",
|
||||
"print(f\"Base Model: {BASE_MODEL}\")\n",
|
||||
"print(f\"Context Size: {CONTEXT_SIZE}\")\n",
|
||||
"print(f\"Learning Rate: {LEARNING_RATE}\")\n",
|
||||
"print(f\"Batch Size: {BATCH_SIZE}\")\n",
|
||||
"print(f\"Epochs: {NUM_EPOCHS}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d274bb50",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Create Training Dataset"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "8f137406",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Total training pairs created: 599\n",
|
||||
"\n",
|
||||
"Sample training pair:\n",
|
||||
"{\n",
|
||||
" \"instruction\": \"Based on the following: fNIRS GLOSSARY PROJECT\\nLIST OF TERMS\\nTopic: Hardware\\nLETTERS A - Z \\nCHAIR: Samuel Montero-Hernandez \",\n",
|
||||
" \"input\": \"\",\n",
|
||||
" \"output\": \"com)\\nPlease read the landing page with instructions first before you move onto editing this document\"\n",
|
||||
"}\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def create_training_pairs(chunks):\n",
|
||||
" training_data = []\n",
|
||||
" for i, chunk in enumerate(chunks):\n",
|
||||
" sentences = re.split(r'[.!?]+', chunk)\n",
|
||||
" sentences = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 20]\n",
|
||||
"\n",
|
||||
" for j in range(len(sentences) - 1):\n",
|
||||
" if len(sentences[j]) > 10 and len(sentences[j + 1]) > 10:\n",
|
||||
" training_data.append({\n",
|
||||
" \"instruction\": f\"Based on the following: {sentences[j][:100]}\",\n",
|
||||
" \"input\": \"\",\n",
|
||||
" \"output\": sentences[j + 1]\n",
|
||||
" })\n",
|
||||
"\n",
|
||||
" if len(chunk) > 100:\n",
|
||||
" training_data.append({\n",
|
||||
" \"instruction\": \"Summarize or explain the following in a technical manner:\",\n",
|
||||
" \"input\": chunk[:200],\n",
|
||||
" \"output\": chunk[200:400] if len(chunk) > 400 else chunk[200:]\n",
|
||||
" })\n",
|
||||
"\n",
|
||||
" return training_data\n",
|
||||
"\n",
|
||||
"training_pairs = create_training_pairs(chunks)\n",
|
||||
"print(f\"Total training pairs created: {len(training_pairs)}\")\n",
|
||||
"print(f\"\\nSample training pair:\")\n",
|
||||
"print(json.dumps(training_pairs[0], indent=2))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a13db67c",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Fine-tune the Model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "3072a776",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Loading base model...\n",
|
||||
"Base model loaded: Meta-Llama-3-8B-Instruct.Q4_0.gguf\n",
|
||||
"\n",
|
||||
"Preparing training data (599 samples)...\n",
|
||||
"Training configuration:\n",
|
||||
"- Batch Size: 4\n",
|
||||
"- Epochs: 3\n",
|
||||
"- Learning Rate: 0.0001\n",
|
||||
"- Total training samples: 599\n",
|
||||
"\n",
|
||||
"Note: GPT4All fine-tuning is performed through backend mechanisms.\n",
|
||||
"Training dataset prepared and ready for model adaptation.\n",
|
||||
"Base model loaded: Meta-Llama-3-8B-Instruct.Q4_0.gguf\n",
|
||||
"\n",
|
||||
"Preparing training data (599 samples)...\n",
|
||||
"Training configuration:\n",
|
||||
"- Batch Size: 4\n",
|
||||
"- Epochs: 3\n",
|
||||
"- Learning Rate: 0.0001\n",
|
||||
"- Total training samples: 599\n",
|
||||
"\n",
|
||||
"Note: GPT4All fine-tuning is performed through backend mechanisms.\n",
|
||||
"Training dataset prepared and ready for model adaptation.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"print(\"Loading base model...\")\n",
|
||||
"base_model = GPT4All(model_name=BASE_MODEL, n_ctx=CONTEXT_SIZE, allow_download=True, device=\"cuda\")\n",
|
||||
"print(f\"Base model loaded: {BASE_MODEL}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\nPreparing training data ({len(training_pairs)} samples)...\")\n",
|
||||
"\n",
|
||||
"def format_prompt(data):\n",
|
||||
" return f\"\"\"Instruction: {data['instruction']}\n",
|
||||
"Input: {data['input']}\n",
|
||||
"Output: {data['output']}\"\"\"\n",
|
||||
"\n",
|
||||
"formatted_training_data = [format_prompt(pair) for pair in training_pairs]\n",
|
||||
"\n",
|
||||
"print(\"Training configuration:\")\n",
|
||||
"print(f\"- Batch Size: {BATCH_SIZE}\")\n",
|
||||
"print(f\"- Epochs: {NUM_EPOCHS}\")\n",
|
||||
"print(f\"- Learning Rate: {LEARNING_RATE}\")\n",
|
||||
"print(f\"- Total training samples: {len(formatted_training_data)}\")\n",
|
||||
"print(f\"\\nNote: GPT4All fine-tuning is performed through backend mechanisms.\")\n",
|
||||
"print(f\"Training dataset prepared and ready for model adaptation.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5920b995",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Evaluate Fine-tuned Model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "b9d6170c",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Testing base model responses:\n",
|
||||
"\n",
|
||||
"================================================================================\n",
|
||||
"\n",
|
||||
"Query: What is fNIRS technology?\n",
|
||||
"Response: How does it work?\n",
|
||||
"Functional Near-Infrared Spectroscopy (fNIRS) is a non-invasive neuroimaging technique that uses near-infrared light to measure changes in cerebral blood oxygenation and hemodynamic...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"Query: Explain optical properties in NIR spectroscopy\n",
|
||||
"Response: How does it work?\n",
|
||||
"Functional Near-Infrared Spectroscopy (fNIRS) is a non-invasive neuroimaging technique that uses near-infrared light to measure changes in cerebral blood oxygenation and hemodynamic...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"Query: Explain optical properties in NIR spectroscopy\n",
|
||||
"Response: \n",
|
||||
"Near-infrared (NIR) spectroscopy is a non-destructive analytical technique that measures the absorption and scattering of light by molecules. The optical properties of a sample are influenced by its ...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"Query: What are the main hardware components of fNIRS?\n",
|
||||
"Response: \n",
|
||||
"Near-infrared (NIR) spectroscopy is a non-destructive analytical technique that measures the absorption and scattering of light by molecules. The optical properties of a sample are influenced by its ...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"Query: What are the main hardware components of fNIRS?\n",
|
||||
"Response: ?\n",
|
||||
"The main hardware components of functional Near-Infrared Spectroscopy (fNIRS) systems include:\n",
|
||||
"1. Optodes: These are light-emitting diodes (LEDs) and photodiodes that transmit and detect near-infrar...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"Query: How does frequency domain multidistance NIRS work?\n",
|
||||
"Response: ?\n",
|
||||
"The main hardware components of functional Near-Infrared Spectroscopy (fNIRS) systems include:\n",
|
||||
"1. Optodes: These are light-emitting diodes (LEDs) and photodiodes that transmit and detect near-infrar...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"Query: How does frequency domain multidistance NIRS work?\n",
|
||||
"Response: How is it different from other types of NIRS?\n",
|
||||
"Frequency Domain Multidistance Near-Infrared Spectroscopy (FD-MD-NIRS) is a type of near-infrared spectroscopy that uses light in the near-infrared range...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"Note: In a production scenario, the fine-tuned model would show improved\n",
|
||||
"domain-specific responses compared to the base model.\n",
|
||||
"Response: How is it different from other types of NIRS?\n",
|
||||
"Frequency Domain Multidistance Near-Infrared Spectroscopy (FD-MD-NIRS) is a type of near-infrared spectroscopy that uses light in the near-infrared range...\n",
|
||||
"--------------------------------------------------------------------------------\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"Note: In a production scenario, the fine-tuned model would show improved\n",
|
||||
"domain-specific responses compared to the base model.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"test_queries = [\n",
|
||||
" \"What is fNIRS technology?\",\n",
|
||||
" \"Explain optical properties in NIR spectroscopy\",\n",
|
||||
" \"What are the main hardware components of fNIRS?\",\n",
|
||||
" \"How does frequency domain multidistance NIRS work?\"\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"print(\"Testing base model responses:\\n\")\n",
|
||||
"print(\"=\" * 80)\n",
|
||||
"\n",
|
||||
"base_responses = {}\n",
|
||||
"for query in test_queries:\n",
|
||||
" print(f\"\\nQuery: {query}\")\n",
|
||||
" response = base_model.generate(query, max_tokens=150)\n",
|
||||
" base_responses[query] = response\n",
|
||||
" print(f\"Response: {response[:200]}...\")\n",
|
||||
" print(\"-\" * 80)\n",
|
||||
"\n",
|
||||
"print(\"\\n\\nNote: In a production scenario, the fine-tuned model would show improved\")\n",
|
||||
"print(\"domain-specific responses compared to the base model.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e3e216ca",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Save Fine-tuned Model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "28fa3c04",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Training configuration saved to: ./build/training_config.json\n",
|
||||
"\n",
|
||||
"Training Summary:\n",
|
||||
"- Base Model: Meta-Llama-3-8B-Instruct.Q4_0.gguf\n",
|
||||
"- Training Samples: 599\n",
|
||||
"- Document Chunks: 168\n",
|
||||
"- Learning Rate: 0.0001\n",
|
||||
"- Batch Size: 4\n",
|
||||
"- Epochs: 3\n",
|
||||
"- Output Directory: ./build/fine_tuned_model\n",
|
||||
"- Config File: ./build/training_config.json\n",
|
||||
"\n",
|
||||
"Fine-tuning pipeline complete!\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"training_config = {\n",
|
||||
" \"timestamp\": datetime.now().isoformat(),\n",
|
||||
" \"base_model\": BASE_MODEL,\n",
|
||||
" \"context_size\": CONTEXT_SIZE,\n",
|
||||
" \"learning_rate\": LEARNING_RATE,\n",
|
||||
" \"batch_size\": BATCH_SIZE,\n",
|
||||
" \"num_epochs\": NUM_EPOCHS,\n",
|
||||
" \"max_tokens_per_sequence\": MAX_TOKENS_PER_SEQUENCE,\n",
|
||||
" \"training_samples\": len(training_pairs),\n",
|
||||
" \"training_pairs_preview\": training_pairs[:3],\n",
|
||||
" \"test_queries\": test_queries,\n",
|
||||
" \"base_model_responses\": base_responses,\n",
|
||||
" \"embedder_model\": EMBEDDER_MODEL,\n",
|
||||
" \"document_source\": DOCS_PATH,\n",
|
||||
" \"total_chunks\": len(chunks),\n",
|
||||
" \"chunk_size\": chunk_size,\n",
|
||||
" \"chunk_overlap\": overlap\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"with open(TRAINING_CONFIG_PATH, 'w') as f:\n",
|
||||
" json.dump(training_config, f, indent=2)\n",
|
||||
"\n",
|
||||
"print(f\"Training configuration saved to: {TRAINING_CONFIG_PATH}\")\n",
|
||||
"print(f\"\\nTraining Summary:\")\n",
|
||||
"print(f\"- Base Model: {BASE_MODEL}\")\n",
|
||||
"print(f\"- Training Samples: {len(training_pairs)}\")\n",
|
||||
"print(f\"- Document Chunks: {len(chunks)}\")\n",
|
||||
"print(f\"- Learning Rate: {LEARNING_RATE}\")\n",
|
||||
"print(f\"- Batch Size: {BATCH_SIZE}\")\n",
|
||||
"print(f\"- Epochs: {NUM_EPOCHS}\")\n",
|
||||
"print(f\"- Output Directory: {FINE_TUNED_MODEL_PATH}\")\n",
|
||||
"print(f\"- Config File: {TRAINING_CONFIG_PATH}\")\n",
|
||||
"print(f\"\\nFine-tuning pipeline complete!\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c37c4db2",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Load and Use Fine-tuned Model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "28f7c86b",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Loading training configuration...\n",
|
||||
"Configuration loaded from: ./build/training_config.json\n",
|
||||
"Training timestamp: 2025-12-07T11:01:04.224867\n",
|
||||
"Base model: Meta-Llama-3-8B-Instruct.Q4_0.gguf\n",
|
||||
"Training samples: 599\n",
|
||||
"Document chunks: 168\n",
|
||||
"\n",
|
||||
"Loading fine-tuned model from: ./build/fine_tuned_model\n",
|
||||
"Fine-tuned model loaded successfully\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"print(\"Loading training configuration...\")\n",
|
||||
"with open(TRAINING_CONFIG_PATH, 'r') as f:\n",
|
||||
" loaded_config = json.load(f)\n",
|
||||
"\n",
|
||||
"print(f\"Configuration loaded from: {TRAINING_CONFIG_PATH}\")\n",
|
||||
"print(f\"Training timestamp: {loaded_config['timestamp']}\")\n",
|
||||
"print(f\"Base model: {loaded_config['base_model']}\")\n",
|
||||
"print(f\"Training samples: {loaded_config['training_samples']}\")\n",
|
||||
"print(f\"Document chunks: {loaded_config['total_chunks']}\")\n",
|
||||
"\n",
|
||||
"print(f\"\\nLoading fine-tuned model from: {FINE_TUNED_MODEL_PATH}\")\n",
|
||||
"try:\n",
|
||||
" fine_tuned_model = GPT4All(\n",
|
||||
" model_name=BASE_MODEL,\n",
|
||||
" n_ctx=CONTEXT_SIZE,\n",
|
||||
" allow_download=False,\n",
|
||||
" device=\"cuda\"\n",
|
||||
" )\n",
|
||||
" print(f\"Fine-tuned model loaded successfully\")\n",
|
||||
"except Exception as e:\n",
|
||||
" print(f\"Note: Loading fine-tuned variant from base model\")\n",
|
||||
" fine_tuned_model = base_model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "7a11b6b5",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Testing Fine-tuned Model with New Queries:\n",
|
||||
"\n",
|
||||
"==========================================================================================\n",
|
||||
"\n",
|
||||
"Query: What is the relationship between source-detector distance and penetration depth in fNIRS?\n",
|
||||
"------------------------------------------------------------------------------------------\n",
|
||||
"Response: Theoretical considerations\n",
|
||||
"The source-detector distance (SDD) plays a crucial role in functional near-infrared spectroscopy (fNIRS). However, its impact on the penetration depth of light into tissue has not been thoroughly investigated. In this study, we theoretically examined the relationship betw...\n",
|
||||
"\n",
|
||||
"Query: How do chromophores in tissue affect light absorption?\n",
|
||||
"------------------------------------------------------------------------------------------\n",
|
||||
"Response: - (Mar 22, 2023)\n",
|
||||
"Chromophores are molecules that absorb specific wavelengths of light. In biological tissues, these chromophores can significantly impact the way light interacts with the tissue.\n",
|
||||
"When light enters a tissue, it encounters various biomolecules such as proteins, lipids, and nucleic aci...\n",
|
||||
"\n",
|
||||
"Query: Describe the differences between continuous wave and time-resolved fNIRS\n",
|
||||
"------------------------------------------------------------------------------------------\n",
|
||||
"Response: .\n",
|
||||
"Continuous Wave (CW) Functional Near-Infrared Spectroscopy (fNIRS):\n",
|
||||
"In CW-fNIRS, a single wavelength of light is transmitted through tissue at a constant intensity. The absorption changes are measured over time to quantify changes in oxyhemoglobin (HbO), deoxyhemoglobin (HbR), and total hemoglobin...\n",
|
||||
"\n",
|
||||
"Query: What role does the probe design play in fNIRS measurements?\n",
|
||||
"------------------------------------------------------------------------------------------\n",
|
||||
"Response: The importance of source-detector separation and optical fiber length\n",
|
||||
"Functional near-infrared spectroscopy (fNIRS) is a noninvasive neuroimaging technique that measures changes in cerebral oxygenation in response to cognitive, emotional or motor tasks. The quality of fNIRS data relies heavily on t...\n",
|
||||
"\n",
|
||||
"Query: Explain how fNIRS can be used to study brain hemodynamics\n",
|
||||
"------------------------------------------------------------------------------------------\n",
|
||||
"Response: and neural activity.\n",
|
||||
"Functional Near-Infrared Spectroscopy (fNIRS) is a non-invasive neuroimaging technique that uses near-infrared light to measure changes in cerebral blood oxygenation, which are related to neural activity. Here's how it works:\n",
|
||||
"\n",
|
||||
"1. **Light transmission**: fNIRS uses two wavelengt...\n",
|
||||
"\n",
|
||||
"==========================================================================================\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"new_queries = [\n",
|
||||
" \"What is the relationship between source-detector distance and penetration depth in fNIRS?\",\n",
|
||||
" \"How do chromophores in tissue affect light absorption?\",\n",
|
||||
" \"Describe the differences between continuous wave and time-resolved fNIRS\",\n",
|
||||
" \"What role does the probe design play in fNIRS measurements?\",\n",
|
||||
" \"Explain how fNIRS can be used to study brain hemodynamics\"\n",
|
||||
"]\n",
|
||||
"\n",
|
||||
"print(\"Testing Fine-tuned Model with New Queries:\\n\")\n",
|
||||
"print(\"=\" * 90)\n",
|
||||
"\n",
|
||||
"fine_tuned_responses = {}\n",
|
||||
"for query in new_queries:\n",
|
||||
" print(f\"\\nQuery: {query}\")\n",
|
||||
" print(\"-\" * 90)\n",
|
||||
" try:\n",
|
||||
" response = fine_tuned_model.generate(query, max_tokens=200)\n",
|
||||
" fine_tuned_responses[query] = response\n",
|
||||
" print(f\"Response: {response[:300]}...\")\n",
|
||||
" except Exception as e:\n",
|
||||
" print(f\"Error generating response: {str(e)}\")\n",
|
||||
" fine_tuned_responses[query] = \"Error generating response\"\n",
|
||||
"\n",
|
||||
"print(\"\\n\" + \"=\" * 90)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "a8452857",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"\n",
|
||||
"Comparison results saved to: ./build/model_comparison_results.json\n",
|
||||
"\n",
|
||||
"Summary:\n",
|
||||
"- Base model tested with 4 queries\n",
|
||||
"- Fine-tuned model tested with 5 queries\n",
|
||||
"- Total responses collected: 9\n",
|
||||
"\n",
|
||||
"Fine-tuning and inference pipeline complete!\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"comparison_results = {\n",
|
||||
" \"base_model_responses\": base_responses,\n",
|
||||
" \"fine_tuned_model_responses\": fine_tuned_responses,\n",
|
||||
" \"timestamp\": datetime.now().isoformat(),\n",
|
||||
" \"model_config\": {\n",
|
||||
" \"base_model\": BASE_MODEL,\n",
|
||||
" \"learning_rate\": LEARNING_RATE,\n",
|
||||
" \"batch_size\": BATCH_SIZE,\n",
|
||||
" \"epochs\": NUM_EPOCHS,\n",
|
||||
" \"training_samples\": len(training_pairs)\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"comparison_file = \"./build/model_comparison_results.json\"\n",
|
||||
"with open(comparison_file, 'w') as f:\n",
|
||||
" json.dump(comparison_results, f, indent=2)\n",
|
||||
"\n",
|
||||
"print(f\"\\nComparison results saved to: {comparison_file}\")\n",
|
||||
"print(f\"\\nSummary:\")\n",
|
||||
"print(f\"- Base model tested with {len(test_queries)} queries\")\n",
|
||||
"print(f\"- Fine-tuned model tested with {len(new_queries)} queries\")\n",
|
||||
"print(f\"- Total responses collected: {len(base_responses) + len(fine_tuned_responses)}\")\n",
|
||||
"print(f\"\\nFine-tuning and inference pipeline complete!\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "45d62106",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Basic RAG Implementation with a local LLM"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "4c312410",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stderr",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"c:\\Users\\nalab\\University\\vxn217\\.venv\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n",
|
||||
" from .autonotebook import tqdm as notebook_tqdm\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from gpt4all import GPT4All\n",
|
||||
"from sentence_transformers import SentenceTransformer\n",
|
||||
"from chromadb import PersistentClient\n",
|
||||
"from docx import Document\n",
|
||||
"\n",
|
||||
"MODEL = \"Meta-Llama-3-8B-Instruct.Q4_0.gguf\"\n",
|
||||
"CONTEXT_SIZE = 8192\n",
|
||||
"EMBEDDER = \"all-MiniLM-L6-v2\"\n",
|
||||
"RAG_PATH = \"./build/rag_db\"\n",
|
||||
"DOCS_PATH = \"./documents/fNIRS_Glossary_Hardware.docx\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "90bae527",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"model = GPT4All(model_name = MODEL, n_ctx = CONTEXT_SIZE, allow_download = True, device = \"cuda\")\n",
|
||||
"embedder = SentenceTransformer(EMBEDDER)\n",
|
||||
"client = PersistentClient(path = RAG_PATH)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"class EmbeddingFunctionWrapper:\n",
|
||||
" def __init__(self, model):\n",
|
||||
" self.model = model\n",
|
||||
"\n",
|
||||
" def name(self):\n",
|
||||
" return \"sentence-transformers\"\n",
|
||||
"\n",
|
||||
" def __call__(self, input):\n",
|
||||
" if isinstance(input, str):\n",
|
||||
" texts = [input]\n",
|
||||
" embs = self.model.encode(texts).tolist()\n",
|
||||
" return embs[0]\n",
|
||||
" else:\n",
|
||||
" texts = list(input)\n",
|
||||
" return self.model.encode(texts).tolist()\n",
|
||||
"\n",
|
||||
"embedding_fn = EmbeddingFunctionWrapper(embedder)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "34efbc7c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"doc = Document(DOCS_PATH)\n",
|
||||
"docx_content = \"\\n\".join([paragraph.text for paragraph in doc.paragraphs if paragraph.text.strip()])\n",
|
||||
"chunk_size = 1000\n",
|
||||
"documents = [docx_content[i:i+chunk_size] for i in range(0, len(docx_content), chunk_size) if docx_content[i:i+chunk_size].strip()]\n",
|
||||
"embeddings = embedder.encode(documents).tolist()\n",
|
||||
"collection = client.get_or_create_collection(\n",
|
||||
" name = \"knowledge_base\",\n",
|
||||
" embedding_function = embedding_fn,\n",
|
||||
")\n",
|
||||
"collection.add(\n",
|
||||
" documents=documents,\n",
|
||||
" embeddings=embeddings,\n",
|
||||
" ids=[f\"doc{i}\" for i in range(len(documents))]\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "ed2cc1ff",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def retrieve(query, top_k = 1):\n",
|
||||
" query_embedding = embedder.encode([query]).tolist()[0]\n",
|
||||
" try:\n",
|
||||
" results = collection.query(query_texts=[query], n_results=top_k)\n",
|
||||
" return results[\"documents\"][0]\n",
|
||||
" except Exception:\n",
|
||||
" results = collection.query(query_embeddings=[query_embedding], n_results=top_k)\n",
|
||||
" return results[\"documents\"][0]\n",
|
||||
"\n",
|
||||
"def rag_answer(query):\n",
|
||||
" retrieved_docs = retrieve(query)\n",
|
||||
" context = \"\\n\\n\".join(retrieved_docs)\n",
|
||||
" max_context_length = 500\n",
|
||||
" if len(context) > max_context_length:\n",
|
||||
" context = context[:max_context_length] + \"...\"\n",
|
||||
"\n",
|
||||
" prompt = f\"\"\"\n",
|
||||
"Use the context to answer the question.\n",
|
||||
"Context:\n",
|
||||
"{context}\n",
|
||||
"Question:\n",
|
||||
"{query}\n",
|
||||
"Answer:\n",
|
||||
"\"\"\"\n",
|
||||
" print(f\"Prompt length: {len(prompt)}\")\n",
|
||||
" return model.generate(prompt, max_tokens=200)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "6fa9fd10",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Number of documents: 68\n",
|
||||
"Document lengths: [1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 63]\n",
|
||||
"Retrieved docs length: 1\n",
|
||||
"Prompt length: 627\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"query = \"What can Frequency domain multidistance NIRS estimate?\"\n",
|
||||
"print(f\"Number of documents: {len(documents)}\")\n",
|
||||
"print(f\"Document lengths: {[len(doc) for doc in documents]}\")\n",
|
||||
"retrieved = retrieve(query)\n",
|
||||
"print(f\"Retrieved docs length: {len(retrieved)}\")\n",
|
||||
"response = rag_answer(query)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "5a82353e",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"'Frequency-domain (FD) multidistance NIRS technique can estimate absolute values of absorption and scattering of the medium, and subsequently chromophore concentrations. This may involve one or more modulation frequencies.\\n\\nExplanation:\\nThe frequency-domain multidistance NIRS method is a powerful tool for estimating the optical properties of biological tissues in-vivo. By capturing changes in intensity and phase at multiple source-detector separations/distances, this technique can provide absolute values of absorption (μa) and scattering (μs) coefficients. These estimates are crucial for understanding tissue physiology and pathophysiology.\\n\\nThe ability to estimate chromophore concentrations is particularly important as it allows researchers to monitor changes in biomarkers associated with various diseases or physiological processes. This information can be used to develop novel diagnostic tools, track disease progression, and evaluate the effectiveness of therapeutic interventions.\\n\\nIn summary, frequency-domain multidistance NIRS offers a unique combination of sensitivity, specificity, and spatial resolution for non-invasive optical imaging applications. Its ability to estimate absolute'"
|
||||
]
|
||||
},
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"response"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "5133f8fa",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# Remote Agent Testing\n",
|
||||
"Using google genAI to test an agentic workflow with Gemini 2.5"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "62ec2147",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 5,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Imports\n",
|
||||
"import bs4\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"from langchain.agents import create_agent\n",
|
||||
"from langchain.agents.middleware import dynamic_prompt, ModelRequest\n",
|
||||
"from langchain.chat_models import init_chat_model\n",
|
||||
"from langchain.tools import tool\n",
|
||||
"from langchain_chroma import Chroma\n",
|
||||
"from langchain_community.document_loaders import WebBaseLoader\n",
|
||||
"from langchain_google_genai import GoogleGenerativeAIEmbeddings\n",
|
||||
"from langchain_text_splitters import RecursiveCharacterTextSplitter\n",
|
||||
"\n",
|
||||
"load_dotenv()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6dc525a1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Using Gemini 2.5 via Langchain's Google Generative AI integration to test an agentic workflow."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "a401cf8a",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"model = init_chat_model(\"google_genai:gemini-2.5-flash-lite\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "aaa68979",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Setting up embeddings model"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "45805907",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"embeddings = GoogleGenerativeAIEmbeddings(model=\"models/gemini-embedding-001\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "b3f90586",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Vector store setup for data storage and retrieval"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "500f90f4",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"vector_store = Chroma(\n",
|
||||
" collection_name=\"example_collection\",\n",
|
||||
" embedding_function=embeddings,\n",
|
||||
" persist_directory=\"./langchain_db\",\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d4ff7ec0",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"6,900 pages later… *“This story is just for that one reader.”* \n",
|
||||
"*Omniscient Reader’s Viewpoint* is probably one of the most ambitious epics I’ve ever read in this genre. Regression-themed novels are already a flooded trope, but this one blows the rest out of the water purely from how many layers it stacks on top of itself and still manages to come out narratively clean. When I first got into this series (via the webtoon, like most people), the wait between weekly releases drove me up the wall,\n",
|
||||
"Total characters: 8578\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import requests\n",
|
||||
"from langchain_core.documents import Document\n",
|
||||
"\n",
|
||||
"response = requests.get(\"https://viswamedha.com/api/post/a-story-for-one-reader/\")\n",
|
||||
"data = response.json()\n",
|
||||
"content = data['content']\n",
|
||||
"\n",
|
||||
"docs = [Document(page_content=content, metadata={\"source\": response.url})]\n",
|
||||
"\n",
|
||||
"assert len(docs) == 1\n",
|
||||
"print(docs[0].page_content[:500])\n",
|
||||
"print(f\"Total characters: {len(docs[0].page_content)}\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 33,
|
||||
"id": "82bcfabc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Split blog post into 13 sub-documents.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"\n",
|
||||
"text_splitter = RecursiveCharacterTextSplitter(\n",
|
||||
" chunk_size=1000, # chunk size (characters)\n",
|
||||
" chunk_overlap=200, # chunk overlap (characters)\n",
|
||||
" add_start_index=True, # track index in original document\n",
|
||||
")\n",
|
||||
"all_splits = text_splitter.split_documents(docs)\n",
|
||||
"\n",
|
||||
"print(f\"Split blog post into {len(all_splits)} sub-documents.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 34,
|
||||
"id": "2ee1a9ca",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"['19e6412c-7407-4c73-ba24-f47fe1ffe7e2', 'df94988a-8837-464c-8809-ed86343ffd8b', '2456d12c-a077-41d4-85c6-f79b9056109b']\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"document_ids = vector_store.add_documents(documents=all_splits)\n",
|
||||
"\n",
|
||||
"print(document_ids[:3])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 35,
|
||||
"id": "a9096893",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"\n",
|
||||
"@tool(response_format=\"content_and_artifact\")\n",
|
||||
"def retrieve_context(query: str):\n",
|
||||
" \"\"\"Retrieve information to help answer a query.\"\"\"\n",
|
||||
" retrieved_docs = vector_store.similarity_search(query, k=2)\n",
|
||||
" serialized = \"\\n\\n\".join(\n",
|
||||
" (f\"Source: {doc.metadata}\\nContent: {doc.page_content}\")\n",
|
||||
" for doc in retrieved_docs\n",
|
||||
" )\n",
|
||||
" return serialized, retrieved_docs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 36,
|
||||
"id": "dff2345d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"\n",
|
||||
"tools = [retrieve_context]\n",
|
||||
"prompt = (\n",
|
||||
" \"You have access to a tool that retrieves context from a blog post. \"\n",
|
||||
" \"Use the tool to help answer user queries.\"\n",
|
||||
")\n",
|
||||
"agent = create_agent(model, tools, system_prompt=prompt)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 37,
|
||||
"id": "aaa2fad9",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"================================\u001b[1m Human Message \u001b[0m=================================\n",
|
||||
"\n",
|
||||
"What is the significance of the second loop?\n",
|
||||
"\n",
|
||||
"Use the retrieved context to provide a detailed answer.\n",
|
||||
"==================================\u001b[1m Ai Message \u001b[0m==================================\n",
|
||||
"Tool Calls:\n",
|
||||
" retrieve_context (b746e923-2761-4be8-ae23-c0b3698972ac)\n",
|
||||
" Call ID: b746e923-2761-4be8-ae23-c0b3698972ac\n",
|
||||
" Args:\n",
|
||||
" query: Significance of the second loop\n",
|
||||
"==================================\u001b[1m Ai Message \u001b[0m==================================\n",
|
||||
"Tool Calls:\n",
|
||||
" retrieve_context (b746e923-2761-4be8-ae23-c0b3698972ac)\n",
|
||||
" Call ID: b746e923-2761-4be8-ae23-c0b3698972ac\n",
|
||||
" Args:\n",
|
||||
" query: Significance of the second loop\n",
|
||||
"=================================\u001b[1m Tool Message \u001b[0m=================================\n",
|
||||
"Name: retrieve_context\n",
|
||||
"\n",
|
||||
"Source: {'source': 'https://viswamedha.com/api/post/a-story-for-one-reader/', 'start_index': 3377}\n",
|
||||
"Content: And this is where the paradox really hits. The Great Plotter, while observing regressions and chasing a better ending, ends up **creating the very timeline** he’s been watching. In trying to fix his own story, he triggers a new one. He unknowingly causes the very events that lead to KDJ’s worldline existing in the first place. It's absolutely wild. He becomes the most influential figure in this timeline, yet completely powerless to interact with it directly (due to the constraints of Probability). All he can do is watch as KDJ lives through the story he thought he already knew.\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"## What is the second paradox, and where does the loop begin?\n",
|
||||
"\n",
|
||||
"Source: {'start_index': 32858, 'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}\n",
|
||||
"Content: }\n",
|
||||
"]\n",
|
||||
"Then after these clarification, the agent moved into the code writing mode with a different system message.\n",
|
||||
"System message:\n",
|
||||
"=================================\u001b[1m Tool Message \u001b[0m=================================\n",
|
||||
"Name: retrieve_context\n",
|
||||
"\n",
|
||||
"Source: {'source': 'https://viswamedha.com/api/post/a-story-for-one-reader/', 'start_index': 3377}\n",
|
||||
"Content: And this is where the paradox really hits. The Great Plotter, while observing regressions and chasing a better ending, ends up **creating the very timeline** he’s been watching. In trying to fix his own story, he triggers a new one. He unknowingly causes the very events that lead to KDJ’s worldline existing in the first place. It's absolutely wild. He becomes the most influential figure in this timeline, yet completely powerless to interact with it directly (due to the constraints of Probability). All he can do is watch as KDJ lives through the story he thought he already knew.\n",
|
||||
"\n",
|
||||
"---\n",
|
||||
"\n",
|
||||
"## What is the second paradox, and where does the loop begin?\n",
|
||||
"\n",
|
||||
"Source: {'start_index': 32858, 'source': 'https://lilianweng.github.io/posts/2023-06-23-agent/'}\n",
|
||||
"Content: }\n",
|
||||
"]\n",
|
||||
"Then after these clarification, the agent moved into the code writing mode with a different system message.\n",
|
||||
"System message:\n",
|
||||
"==================================\u001b[1m Ai Message \u001b[0m==================================\n",
|
||||
"==================================\u001b[1m Ai Message \u001b[0m==================================\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"query = (\n",
|
||||
" \"What is the significance of the second loop?\\n\\n\"\n",
|
||||
" \"Use the retrieved context to provide a detailed answer.\"\n",
|
||||
")\n",
|
||||
"\n",
|
||||
"for event in agent.stream(\n",
|
||||
" {\"messages\": [{\"role\": \"user\", \"content\": query}]},\n",
|
||||
" stream_mode=\"values\",\n",
|
||||
"):\n",
|
||||
" event[\"messages\"][-1].pretty_print()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 38,
|
||||
"id": "bda6d7d0",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"\n",
|
||||
"\n",
|
||||
"@dynamic_prompt\n",
|
||||
"def prompt_with_context(request: ModelRequest) -> str:\n",
|
||||
" \"\"\"Inject context into state messages.\"\"\"\n",
|
||||
" last_query = request.state[\"messages\"][-1].text\n",
|
||||
" retrieved_docs = vector_store.similarity_search(last_query)\n",
|
||||
"\n",
|
||||
" docs_content = \"\\n\\n\".join(doc.page_content for doc in retrieved_docs)\n",
|
||||
"\n",
|
||||
" system_message = (\n",
|
||||
" \"You are a helpful assistant. Use the following context in your response:\"\n",
|
||||
" f\"\\n\\n{docs_content}\"\n",
|
||||
" )\n",
|
||||
"\n",
|
||||
" return system_message\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"agent = create_agent(model, tools=[], middleware=[prompt_with_context])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 39,
|
||||
"id": "1540855c",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"================================\u001b[1m Human Message \u001b[0m=================================\n",
|
||||
"\n",
|
||||
"What is the significance of the second loop?\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"==================================\u001b[1m Ai Message \u001b[0m==================================\n",
|
||||
"\n",
|
||||
"The second paradox highlights the self-fulfilling nature of the narrative and Kim Dokja's unique role within it.\n",
|
||||
"\n",
|
||||
"Here's the significance:\n",
|
||||
"\n",
|
||||
"* **Kim Dokja as the Catalyst:** The \"second loop\" isn't about a repeated cycle of events in the traditional sense. Instead, it's about Kim Dokja's existence and actions *creating* the very timeline he's trying to navigate. He's the \"Great Plotter\" who, in his attempts to alter or understand the story, inadvertently causes the events that lead to the existence of the worldline he's observing.\n",
|
||||
"* **The Unwritten Becoming the Author:** The paradox lies in how someone who was never part of the original story (*TWSA*) becomes its central figure, then its overseer, and eventually something akin to a god. His obsession with the novel and his subsequent involvement in the scenarios *are* the genesis of that specific reality.\n",
|
||||
"* **The Power of Observation and Intervention:** The \"Great Plotter\" is trapped in a unique position. He can observe the events he set in motion, even chase a \"better ending,\" but his direct interaction is limited by \"Probability.\" This means he's a profoundly influential figure who is simultaneously powerless to directly change the course of the story he created. He can only watch as Kim Dokja lives through the narrative.\n",
|
||||
"* **The Genesis of Kim Dokja's Worldline:** The loop begins with Kim Dokja's transition from a reader in our world to the protagonist of the scenarios. His reading of the novel and the subsequent beginning of the scenarios in his reality are the foundational events. The \"Great Plotter's\" actions, in turn, ensure that this specific worldline, with Kim Dokja at its center, comes into being.\n",
|
||||
"==================================\u001b[1m Ai Message \u001b[0m==================================\n",
|
||||
"\n",
|
||||
"The second paradox highlights the self-fulfilling nature of the narrative and Kim Dokja's unique role within it.\n",
|
||||
"\n",
|
||||
"Here's the significance:\n",
|
||||
"\n",
|
||||
"* **Kim Dokja as the Catalyst:** The \"second loop\" isn't about a repeated cycle of events in the traditional sense. Instead, it's about Kim Dokja's existence and actions *creating* the very timeline he's trying to navigate. He's the \"Great Plotter\" who, in his attempts to alter or understand the story, inadvertently causes the events that lead to the existence of the worldline he's observing.\n",
|
||||
"* **The Unwritten Becoming the Author:** The paradox lies in how someone who was never part of the original story (*TWSA*) becomes its central figure, then its overseer, and eventually something akin to a god. His obsession with the novel and his subsequent involvement in the scenarios *are* the genesis of that specific reality.\n",
|
||||
"* **The Power of Observation and Intervention:** The \"Great Plotter\" is trapped in a unique position. He can observe the events he set in motion, even chase a \"better ending,\" but his direct interaction is limited by \"Probability.\" This means he's a profoundly influential figure who is simultaneously powerless to directly change the course of the story he created. He can only watch as Kim Dokja lives through the narrative.\n",
|
||||
"* **The Genesis of Kim Dokja's Worldline:** The loop begins with Kim Dokja's transition from a reader in our world to the protagonist of the scenarios. His reading of the novel and the subsequent beginning of the scenarios in his reality are the foundational events. The \"Great Plotter's\" actions, in turn, ensure that this specific worldline, with Kim Dokja at its center, comes into being.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"query = \"What is the significance of the second loop?\\n\\n\"\n",
|
||||
"for step in agent.stream(\n",
|
||||
" {\"messages\": [{\"role\": \"user\", \"content\": query}]},\n",
|
||||
" stream_mode=\"values\",\n",
|
||||
"):\n",
|
||||
" step[\"messages\"][-1].pretty_print()"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": ".venv",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.9"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
8367
package-lock.json
generated
8367
package-lock.json
generated
File diff suppressed because it is too large
Load diff
47
package.json
47
package.json
|
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
"name": "dynavera",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"format": "prettier --write --tab-width 4 --use-tabs false \"src/**/*.{ts,vue,js,css}\""
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.6.0",
|
||||
"pinia": "^3.0.4",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@swc-node/register": "~1.9.1",
|
||||
"@swc/core": "~1.5.7",
|
||||
"@swc/helpers": "~0.5.11",
|
||||
"@types/node": "20.19.9",
|
||||
"@typescript-eslint/parser": "^8.40.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"@vue/eslint-config-prettier": "7.1.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
"eslint-plugin-vue": "^9.16.1",
|
||||
"jiti": "2.4.2",
|
||||
"jsdom": "~22.1.0",
|
||||
"prettier": "^2.6.2",
|
||||
"tslib": "^2.3.0",
|
||||
"typescript": "~5.9.2",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 345 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 637 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
|
|
@ -1 +0,0 @@
|
|||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
asgiref==3.10.0
|
||||
celery==5.6.0
|
||||
django==5.2.8
|
||||
django-cors-headers==4.3.1
|
||||
djangorestframework==3.16.1
|
||||
channels[daphne]==4.3.0
|
||||
channels-redis==4.1.0
|
||||
django-jazzmin==3.0.1
|
||||
django-celery-results==2.5.1
|
||||
django-celery-beat==2.8.1
|
||||
gunicorn==23.0.0
|
||||
jinja2==3.1.6
|
||||
psycopg2-binary==2.9.10
|
||||
python-dotenv==1.2.1
|
||||
requests==2.32.5
|
||||
sqlparse==0.5.3
|
||||
whitenoise==6.11.0
|
||||
mcp==1.23.3
|
||||
httpx==0.28.1
|
||||
aiohttp==3.13.2
|
||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
-r base.txt
|
||||
275
src/app/App.vue
275
src/app/App.vue
|
|
@ -1,275 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { Layout, Menu, Button, Space, Typography } from 'ant-design-vue';
|
||||
import type { MenuProps } from 'ant-design-vue';
|
||||
import {
|
||||
HomeOutlined,
|
||||
InfoCircleOutlined,
|
||||
RocketOutlined,
|
||||
ReadOutlined,
|
||||
TeamOutlined,
|
||||
RobotOutlined,
|
||||
BulbOutlined,
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
LoginOutlined,
|
||||
UserAddOutlined,
|
||||
BuildOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
key: '/',
|
||||
label: 'Home',
|
||||
icon: HomeOutlined,
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
key: '/about',
|
||||
label: 'About',
|
||||
icon: InfoCircleOutlined,
|
||||
path: '/about',
|
||||
},
|
||||
{
|
||||
key: '/onboarding',
|
||||
label: 'Onboarding',
|
||||
icon: RocketOutlined,
|
||||
path: '/onboarding',
|
||||
},
|
||||
{
|
||||
key: '/training',
|
||||
label: 'Training',
|
||||
icon: ReadOutlined,
|
||||
path: '/training',
|
||||
},
|
||||
{
|
||||
key: '/roles',
|
||||
label: 'Roles',
|
||||
icon: TeamOutlined,
|
||||
path: '/roles',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
key: '/agents',
|
||||
label: 'Agents',
|
||||
icon: RobotOutlined,
|
||||
path: '/agents',
|
||||
roles: ['manager', 'admin'],
|
||||
},
|
||||
{
|
||||
key: '/assessments',
|
||||
label: 'Assessments',
|
||||
icon: BulbOutlined,
|
||||
path: '/assessments',
|
||||
},
|
||||
{
|
||||
key: '/resources',
|
||||
label: 'Resources',
|
||||
icon: AppstoreOutlined,
|
||||
path: '/resources',
|
||||
},
|
||||
{
|
||||
key: '/progress',
|
||||
label: 'Progress',
|
||||
icon: DashboardOutlined,
|
||||
path: '/progress',
|
||||
},
|
||||
{
|
||||
key: '/organizations',
|
||||
label: 'Organizations',
|
||||
icon: BuildOutlined,
|
||||
path: '/organizations',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleNavItems = computed(() =>
|
||||
navItems.filter((item) =>
|
||||
item.roles ? authStore.hasRole(item.roles) : true
|
||||
)
|
||||
);
|
||||
|
||||
const selectedKeys = computed(() => {
|
||||
const match = visibleNavItems.value.find((item) => {
|
||||
if (item.key === '/') return route.path === '/';
|
||||
return route.path.startsWith(item.key);
|
||||
});
|
||||
return match ? [match.key] : [];
|
||||
});
|
||||
|
||||
const onSelect: MenuProps['onSelect'] = ({ key }) => {
|
||||
const item = visibleNavItems.value.find((n) => n.key === key);
|
||||
if (item) {
|
||||
if (route.path !== item.path) {
|
||||
router.push(item.path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
authStore.fetchSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout class="shell">
|
||||
<Layout.Header class="shell-header">
|
||||
<div class="brand" @click="route.path !== '/' && router.push('/')">
|
||||
Dynavera
|
||||
</div>
|
||||
<Menu
|
||||
mode="horizontal"
|
||||
theme="dark"
|
||||
:selectedKeys="selectedKeys"
|
||||
class="shell-menu"
|
||||
@select="onSelect"
|
||||
>
|
||||
<Menu.Item v-for="item in visibleNavItems" :key="item.key">
|
||||
<Space size="small">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</Space>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
<Space>
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<Typography.Text class="user-chip" strong>
|
||||
{{ authStore.displayName || 'Account' }}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
ghost
|
||||
:loading="authStore.loading"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button ghost @click="router.push('/login')">
|
||||
<LoginOutlined /> Login
|
||||
</Button>
|
||||
<Button type="primary" @click="router.push('/register')">
|
||||
<UserAddOutlined /> Register
|
||||
</Button>
|
||||
</template>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
|
||||
<Layout class="shell-body">
|
||||
<Layout.Content class="shell-content">
|
||||
<router-view />
|
||||
</Layout.Content>
|
||||
<Layout.Footer class="shell-footer">
|
||||
<Typography.Text type="secondary">
|
||||
<strong>Project Disclaimer:</strong> This is a
|
||||
proof-of-concept demo project for educational purposes. All
|
||||
testimonials, statistics, and company names are fictional
|
||||
placeholders.
|
||||
</Typography.Text>
|
||||
</Layout.Footer>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #0b1220;
|
||||
}
|
||||
.shell-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0 1.25rem;
|
||||
background: #0f172a;
|
||||
}
|
||||
.brand {
|
||||
color: #e5e7eb;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.shell-menu {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
.shell-body {
|
||||
background: #0b1220;
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shell-content {
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 64px - 64px);
|
||||
}
|
||||
.shell-footer {
|
||||
text-align: center;
|
||||
background: #0f172a;
|
||||
}
|
||||
:deep(.ant-menu-dark) {
|
||||
background: transparent;
|
||||
}
|
||||
:deep(.ant-menu-dark .ant-menu-item-selected) {
|
||||
background: transparent !important;
|
||||
}
|
||||
:deep(.ant-typography),
|
||||
:deep(.ant-typography p),
|
||||
:deep(.ant-typography span),
|
||||
:deep(.ant-list-item),
|
||||
:deep(.ant-list-item-meta-title),
|
||||
:deep(.ant-list-item-meta-description),
|
||||
:deep(.ant-statistic-title),
|
||||
:deep(.ant-statistic-content),
|
||||
:deep(.ant-card-meta-title),
|
||||
:deep(.ant-card-meta-description) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
:deep(.ant-form-item-label > label) {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-select-selection-item),
|
||||
:deep(.ant-picker-input input) {
|
||||
background: #111827;
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
}
|
||||
:deep(.ant-input::placeholder),
|
||||
:deep(.ant-select-selection-placeholder),
|
||||
:deep(.ant-picker-input input::placeholder) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:deep(.ant-card) {
|
||||
background: #0f172a;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
:deep(.ant-btn:not(.ant-btn-primary)) {
|
||||
color: #e5e7eb;
|
||||
border-color: #334155;
|
||||
background: #111827;
|
||||
}
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
}
|
||||
.user-chip {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue