Hard reset

This commit is contained in:
Viswamedha Nalabotu 2026-01-17 13:23:52 +00:00
parent 5dee11b68b
commit ba62b44426
124 changed files with 0 additions and 18120 deletions

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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

View file

@ -1,6 +0,0 @@
# Add files here to ignore them from prettier formatting
/dist
/build
/coverage
/.nx/cache
/.nx/workspace-data

View file

@ -1,3 +0,0 @@
{
"singleQuote": true
}

View file

@ -1,7 +0,0 @@
{
"recommendations": [
"nrwl.angular-console",
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint"
]
}

23
.vscode/launch.json vendored
View file

@ -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
View file

@ -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
}
}

View file

@ -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)
│ │
└──────────────────────────────────────────────────────────────────┘
```

View file

@ -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.

View file

View file

@ -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',)

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class AgentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.agents'

View file

@ -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
)

View file

@ -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'],
},
),
]

View file

@ -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'},
),
]

View file

@ -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"

View file

@ -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()),
]

View file

@ -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']

View file

@ -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

View file

@ -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)
})

View file

@ -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')}),
)

View file

@ -1,6 +0,0 @@
from django.apps import AppConfig
class DomainsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.domains'

View file

@ -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,
},
),
]

View file

@ -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',
),
]

View file

@ -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

View file

@ -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']

View file

@ -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)

View file

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View file

@ -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'

View file

View file

@ -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',)

View file

@ -1,5 +0,0 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.users'

View file

@ -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)

View file

@ -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',
},
),
]

View file

@ -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

View file

@ -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']

View file

@ -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)

View file

@ -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)

View file

@ -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'))

View file

@ -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
)

View file

@ -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"]

View file

@ -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:

View file

@ -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"]

View file

@ -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"]

View file

@ -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"]

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -1,3 +0,0 @@
from .celery import app as celery_app
__all__ = ('celery_app',)

View file

@ -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

View file

@ -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)
)
),
})

View file

@ -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()

View file

@ -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')

View file

@ -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),
]

View file

@ -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)

View file

@ -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()

View file

@ -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": []
}
}
]

View file

@ -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.

View file

@ -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: ['*']
}
]
}
]
}
}
];

View file

@ -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>

View file

@ -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()

View file

View file

@ -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

View file

@ -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())

View file

@ -1 +0,0 @@
langchain_db/

File diff suppressed because one or more lines are too long

View file

@ -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
}

View file

@ -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
}

View file

@ -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 Readers Viewpoint* is probably one of the most ambitious epics Ive 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** hes been watching. In trying to fix his own story, he triggers a new one. He unknowingly causes the very events that lead to KDJs 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** hes been watching. In trying to fix his own story, he triggers a new one. He unknowingly causes the very events that lead to KDJs 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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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"}

View file

@ -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.

View file

@ -1 +0,0 @@
-r base.txt

View file

@ -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