Added training files models, viewsets and api paths with frontend upload
This commit is contained in:
parent
1f5ce71e5d
commit
7714fa4d8f
19 changed files with 615 additions and 60 deletions
|
|
@ -31,7 +31,7 @@ class AgentModelAdmin(ModelAdmin):
|
|||
|
||||
@admin.register(Agent)
|
||||
class AgentAdmin(ModelAdmin):
|
||||
list_display = ('id', 'uuid', 'model', 'status', 'started_at', 'completed_at')
|
||||
list_display = ('id', 'uuid', 'model', 'status', 'started_at', 'completed_at', 'organization')
|
||||
search_fields = ('model__name', 'uuid')
|
||||
list_filter = ('status',)
|
||||
inlines = (AgentRunInline,)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ class Migration(migrations.Migration):
|
|||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('orgs', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ class Migration(migrations.Migration):
|
|||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('version', models.CharField(max_length=50)),
|
||||
('path', models.CharField(blank=True, default='', max_length=1024)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Model',
|
||||
|
|
@ -36,6 +38,7 @@ class Migration(migrations.Migration):
|
|||
('description', models.TextField(blank=True, default='')),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='agents', to='orgs.organization')),
|
||||
('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agents', to='mlstore.agentmodel')),
|
||||
],
|
||||
options={
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mlstore', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='agentmodel',
|
||||
name='path',
|
||||
field=models.CharField(blank=True, default='', max_length=1024),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from django.db.models import BigAutoField, CASCADE, CharField, DateTimeField, ForeignKey, JSONField, Model, TextField, UUIDField
|
||||
from apps.users.mixins import TimeStampMixin
|
||||
from apps.users.models import User
|
||||
from apps.orgs.models import Organization
|
||||
from uuid import uuid4
|
||||
|
||||
class AgentModel(Model):
|
||||
|
|
@ -32,6 +33,7 @@ class Agent(TimeStampMixin, Model):
|
|||
uuid = UUIDField(default = uuid4, unique = True, editable = False)
|
||||
|
||||
model = ForeignKey(AgentModel, on_delete = CASCADE, related_name = 'agents')
|
||||
organization = ForeignKey(Organization, on_delete = CASCADE, related_name = 'agents', null = True, blank = True)
|
||||
status = CharField(max_length = 20, choices = STATUS_CHOICES, default = 'idle')
|
||||
|
||||
description = TextField(blank = True, default = '')
|
||||
|
|
@ -68,7 +70,7 @@ class AgentRun(TimeStampMixin, Model):
|
|||
completed_at = DateTimeField(null = True, blank = True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Execution {self.uuid} - {self.agent.name} ({self.status})"
|
||||
return f"Execution {self.uuid} - {self.agent} ({self.status})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Agent Run"
|
||||
|
|
@ -92,7 +94,7 @@ class AgentEvent(Model):
|
|||
timestamp = DateTimeField(auto_now_add = True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.id} - {self.event_type} - {self.execution.agent.name}"
|
||||
return f"{self.id} - {self.event_type} - {self.execution.agent}"
|
||||
|
||||
class Meta:
|
||||
ordering = ['timestamp']
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class AgentSerializer(ModelSerializer):
|
|||
'id',
|
||||
'uuid',
|
||||
'model',
|
||||
'organization',
|
||||
'status',
|
||||
'description',
|
||||
'started_at',
|
||||
|
|
|
|||
|
|
@ -1,17 +1,38 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from django.conf import settings
|
||||
from mcp_agent.mcp_client import MCPClient
|
||||
from .models import AgentModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get reference to the base model cache directory
|
||||
try:
|
||||
from mcp_agent.mcp_server import BASE_MODEL_CACHE_DIR
|
||||
BASE_MODEL_CACHE = BASE_MODEL_CACHE_DIR
|
||||
except ImportError:
|
||||
# Fallback: construct the path manually
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BASE_MODEL_CACHE = os.path.join(project_root, "model", "base-model")
|
||||
|
||||
logger.info(f"Base model cache directory reference: {BASE_MODEL_CACHE}")
|
||||
|
||||
async def _call_mcp(tool: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Internal async helper to call the MCP HTTP bridge via MCPClient."""
|
||||
server_url = getattr(settings, "MCP_AGENT_URL")
|
||||
client = MCPClient(server_url)
|
||||
logger.info(f"MCP: Calling tool '{tool}' on {server_url}")
|
||||
logger.debug(f"MCP: Arguments for '{tool}': {arguments}")
|
||||
try:
|
||||
resp = await client.send(tool, arguments)
|
||||
logger.info(f"MCP: Tool '{tool}' completed successfully")
|
||||
logger.debug(f"MCP: Response from '{tool}': {resp}")
|
||||
return resp
|
||||
except Exception as e:
|
||||
logger.error(f"MCP: Tool '{tool}' failed with error: {str(e)}")
|
||||
raise
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
|
@ -28,13 +49,30 @@ def fine_tune_model(
|
|||
Expects the MCP tool `fine_tune` to accept: {base_model, training_files, hyperparams, name, version}
|
||||
and to return a JSON-like dict containing at least `status` and on success `model_path` and `version`.
|
||||
"""
|
||||
return asyncio.run(_call_mcp("fine_tune", {
|
||||
"base_model": base_model,
|
||||
"training_files": training_files,
|
||||
"hyperparams": hyperparams,
|
||||
"name": name,
|
||||
"version": version,
|
||||
}))
|
||||
logger.info(f"Fine-tuning model: name={name}, version={version}, base_model={base_model}")
|
||||
logger.info(f"Training files count: {len(training_files)}")
|
||||
logger.debug(f"Training files: {training_files}")
|
||||
try:
|
||||
logger.info("Calling MCP fine_tune tool...")
|
||||
result = asyncio.run(_call_mcp("fine_tune", {
|
||||
"base_model": base_model,
|
||||
"training_files": training_files,
|
||||
"hyperparams": hyperparams,
|
||||
"name": name,
|
||||
"version": version,
|
||||
}))
|
||||
logger.info(f"Fine-tune completed: status={result.get('status')}")
|
||||
logger.debug(f"Fine-tune result: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = str(e) if str(e) else f"Unknown error: {type(e).__name__}"
|
||||
logger.error(f"Fine-tune failed: {error_msg}", exc_info=True)
|
||||
# Return a failed response instead of raising
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"error_type": type(e).__name__,
|
||||
}
|
||||
|
||||
|
||||
def load_model_for_inference(model_path: str) -> Dict[str, Any]:
|
||||
|
|
@ -42,7 +80,14 @@ def load_model_for_inference(model_path: str) -> Dict[str, Any]:
|
|||
|
||||
Expects the MCP tool `load_model` with {model_path} returning status info.
|
||||
"""
|
||||
return asyncio.run(_call_mcp("load_model", {"model_path": model_path}))
|
||||
logger.info(f"Loading model for inference: {model_path}")
|
||||
try:
|
||||
result = asyncio.run(_call_mcp("load_model", {"model_path": model_path}))
|
||||
logger.info(f"Model loaded successfully")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load model: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def infer_with_model(model_path: str, prompt: str, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
|
|
@ -50,7 +95,17 @@ def infer_with_model(model_path: str, prompt: str, options: Optional[Dict[str, A
|
|||
|
||||
Calls the MCP tool `infer` with {model_path, prompt, options}.
|
||||
"""
|
||||
return asyncio.run(_call_mcp("infer", {"model_path": model_path, "prompt": prompt, "options": options or {}}))
|
||||
logger.info(f"Running inference with model: {model_path}")
|
||||
logger.debug(f"Prompt length: {len(prompt)} characters")
|
||||
logger.debug(f"Inference options: {options}")
|
||||
try:
|
||||
result = asyncio.run(_call_mcp("infer", {"model_path": model_path, "prompt": prompt, "options": options or {}}))
|
||||
logger.info(f"Inference completed successfully")
|
||||
logger.debug(f"Inference result keys: {list(result.keys()) if isinstance(result, dict) else 'not a dict'}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Inference failed: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def register_model_in_db(name: str, version: str, model_path: str) -> AgentModel:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ from asgiref.sync import async_to_sync
|
|||
from . import services
|
||||
from .models import AgentModel, Agent, AgentRun, AgentEvent
|
||||
import traceback
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
|
|
@ -86,37 +89,59 @@ def _update_agent_status(agent: Agent, status: str):
|
|||
|
||||
@shared_task
|
||||
def start_fine_tune_run_task(execution_id: str):
|
||||
logger.info(f"Fine-tune run task started for execution: {execution_id}")
|
||||
try:
|
||||
execution = AgentRun.objects.get(uuid=execution_id)
|
||||
except AgentRun.DoesNotExist:
|
||||
logger.error(f"Execution not found: {execution_id}")
|
||||
return {"status": "error", "error": "execution_not_found", "execution_id": execution_id}
|
||||
|
||||
agent = execution.agent
|
||||
room_group_name = f"mlstore_agent_{agent.uuid}"
|
||||
logger.info(f"Agent: {agent.uuid}, User: {execution.user.email_address}")
|
||||
|
||||
execution.status = "running"
|
||||
execution.started_at = timezone.now()
|
||||
execution.save()
|
||||
_update_agent_status(agent, "running")
|
||||
logger.info(f"Execution {execution_id} status updated to 'running'")
|
||||
|
||||
from apps.mlstore.services import BASE_MODEL_CACHE
|
||||
logger.info(f"Base model cache directory: {BASE_MODEL_CACHE}")
|
||||
|
||||
input_data = execution.input_data or {}
|
||||
base_model = input_data.get("base_model") or agent.model.name
|
||||
|
||||
training_files = input_data.get("training_files") or []
|
||||
if not training_files and agent.organization:
|
||||
from apps.orgs.models import TrainingFile
|
||||
org_training_files = TrainingFile.objects.filter(
|
||||
organization=agent.organization,
|
||||
is_processed=False
|
||||
).select_related('uploaded_by')
|
||||
training_files = [tf.file.path for tf in org_training_files if tf.file]
|
||||
logger.info(f"Fetched {len(training_files)} training files from organization {agent.organization.name}")
|
||||
|
||||
hyperparams = input_data.get("hyperparams") or {}
|
||||
name = input_data.get("name") or f"{agent.model.name}-ft"
|
||||
version = input_data.get("version") or "v1"
|
||||
logger.info(f"Fine-tune parameters: base_model={base_model}, name={name}, version={version}")
|
||||
|
||||
_send_group_event(room_group_name, "started", {"execution_id": str(execution.uuid), "action": "fine_tune"})
|
||||
_persist_event(execution, "started", {"execution_id": str(execution.uuid), "action": "fine_tune"})
|
||||
|
||||
try:
|
||||
result = services.fine_tune_model(base_model, training_files, hyperparams, name, version)
|
||||
logger.info(f"Fine-tune result received: {result.get('status')}")
|
||||
logger.debug(f"Full fine-tune result: {result}")
|
||||
|
||||
if isinstance(result, dict) and result.get("status") == "completed":
|
||||
model_path = result.get("model_path") or result.get("path") or ""
|
||||
model_version = result.get("version") or version
|
||||
new_model = AgentModel.objects.create(name=name, version=model_version, path=model_path)
|
||||
agent.model = new_model
|
||||
agent.save()
|
||||
logger.info(f"Fine-tune completed. New model created: {new_model.uuid} at {model_path}")
|
||||
|
||||
execution.status = "completed"
|
||||
execution.output_data = {
|
||||
|
|
@ -127,6 +152,7 @@ def start_fine_tune_run_task(execution_id: str):
|
|||
execution.completed_at = timezone.now()
|
||||
execution.save()
|
||||
_update_agent_status(agent, "completed")
|
||||
logger.info(f"Execution {execution_id} completed successfully")
|
||||
|
||||
_send_group_event(room_group_name, "completed", {"execution_id": str(execution.uuid), "model_id": new_model.id, "model_path": model_path})
|
||||
_persist_event(execution, "completed", {"execution_id": str(execution.uuid), "model_id": new_model.id, "model_path": model_path})
|
||||
|
|
@ -142,6 +168,7 @@ def start_fine_tune_run_task(execution_id: str):
|
|||
|
||||
return {"status": "completed", "execution_id": execution_id, "model_id": new_model.id}
|
||||
|
||||
logger.warning(f"Fine-tune did not complete successfully. Status: {result.get('status')}")
|
||||
execution.status = "failed"
|
||||
execution.error_message = str(result)
|
||||
execution.completed_at = timezone.now()
|
||||
|
|
@ -162,6 +189,7 @@ def start_fine_tune_run_task(execution_id: str):
|
|||
return {"status": "failed", "execution_id": execution_id, "result": result}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fine-tune task failed with exception for execution {execution_id}: {str(e)}", exc_info=True)
|
||||
traceback.print_exc()
|
||||
execution.status = "failed"
|
||||
execution.error_message = str(e)
|
||||
|
|
@ -183,24 +211,30 @@ def start_fine_tune_run_task(execution_id: str):
|
|||
|
||||
@shared_task
|
||||
def infer_run_task(execution_id: str):
|
||||
logger.info(f"Inference run task started for execution: {execution_id}")
|
||||
try:
|
||||
execution = AgentRun.objects.get(uuid=execution_id)
|
||||
except AgentRun.DoesNotExist:
|
||||
logger.error(f"Execution not found: {execution_id}")
|
||||
return {"status": "error", "error": "execution_not_found", "execution_id": execution_id}
|
||||
|
||||
agent = execution.agent
|
||||
room_group_name = f"mlstore_agent_{agent.uuid}"
|
||||
logger.info(f"Agent: {agent.uuid}, User: {execution.user.email_address}")
|
||||
|
||||
execution.status = "running"
|
||||
execution.started_at = timezone.now()
|
||||
execution.save()
|
||||
_update_agent_status(agent, "running")
|
||||
logger.info(f"Execution {execution_id} status updated to 'running'")
|
||||
|
||||
input_data = execution.input_data or {}
|
||||
prompt = input_data.get("prompt") or input_data.get("query") or ""
|
||||
options = input_data.get("options") or {}
|
||||
logger.info(f"Prompt length: {len(prompt)} characters")
|
||||
|
||||
if not prompt:
|
||||
logger.warning(f"No prompt provided for inference run {execution_id}")
|
||||
execution.status = "failed"
|
||||
execution.error_message = "prompt_required"
|
||||
execution.completed_at = timezone.now()
|
||||
|
|
@ -223,10 +257,13 @@ def infer_run_task(execution_id: str):
|
|||
|
||||
try:
|
||||
try:
|
||||
logger.info(f"Loading model: {agent.model.path}")
|
||||
services.load_model_for_inference(agent.model.path)
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to preload model: {str(e)}")
|
||||
pass
|
||||
|
||||
logger.info(f"Starting inference with model: {agent.model.path}")
|
||||
result = services.infer_with_model(agent.model.path, prompt, options)
|
||||
|
||||
execution.status = "completed"
|
||||
|
|
@ -234,6 +271,7 @@ def infer_run_task(execution_id: str):
|
|||
execution.completed_at = timezone.now()
|
||||
execution.save()
|
||||
_update_agent_status(agent, "completed")
|
||||
logger.info(f"Inference execution {execution_id} completed successfully")
|
||||
|
||||
_send_group_event(room_group_name, "completed", {"execution_id": str(execution.uuid), "result": result})
|
||||
_persist_event(execution, "completed", {"execution_id": str(execution.uuid), "result": result})
|
||||
|
|
@ -248,6 +286,7 @@ def infer_run_task(execution_id: str):
|
|||
return {"status": "completed", "execution_id": execution_id}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Inference task failed with exception for execution {execution_id}: {str(e)}", exc_info=True)
|
||||
traceback.print_exc()
|
||||
execution.status = "failed"
|
||||
execution.error_message = str(e)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from django.contrib.admin import ModelAdmin, TabularInline, register
|
||||
from apps.orgs.models import Organization, OrganizationInvitation, OrganizationMembership, Role, RoleMembership
|
||||
from apps.orgs.models import Organization, OrganizationInvitation, OrganizationMembership, Role, RoleMembership, TrainingFile
|
||||
|
||||
class OrganizationMembershipInline(TabularInline):
|
||||
model = OrganizationMembership
|
||||
|
|
@ -50,4 +50,12 @@ class RoleAdmin(ModelAdmin):
|
|||
@register(RoleMembership)
|
||||
class RoleMembershipAdmin(ModelAdmin):
|
||||
list_display = ('id', 'user', 'role')
|
||||
raw_id_fields = ('user', 'role')
|
||||
raw_id_fields = ('user', 'role')
|
||||
|
||||
@register(TrainingFile)
|
||||
class TrainingFileAdmin(ModelAdmin):
|
||||
list_display = ('id', 'uuid', 'file_name', 'organization', 'uploaded_by', 'is_processed', 'created_at')
|
||||
search_fields = ('file_name', 'organization__name', 'uploaded_by__email_address')
|
||||
list_filter = ('is_processed', 'created_at')
|
||||
raw_id_fields = ('organization', 'uploaded_by')
|
||||
readonly_fields = ('uuid', 'created_at', 'updated_at')
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
|
@ -103,4 +106,26 @@ class Migration(migrations.Migration):
|
|||
name='members',
|
||||
field=models.ManyToManyField(related_name='roles', through='orgs.RoleMembership', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TrainingFile',
|
||||
fields=[
|
||||
('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.BigAutoField(primary_key=True, serialize=False)),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('file', models.FileField(upload_to='training_files/%Y/%m/%d/')),
|
||||
('file_name', models.CharField(max_length=255)),
|
||||
('file_size', models.IntegerField()),
|
||||
('file_type', models.CharField(max_length=50)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('is_processed', models.BooleanField(default=False)),
|
||||
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='training_files', to='orgs.organization')),
|
||||
('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uploaded_training_files', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Training File',
|
||||
'verbose_name_plural': 'Training Files',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ from datetime import timedelta
|
|||
from uuid import uuid4
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models import BigAutoField, BooleanField, CASCADE, CharField, DateTimeField, ForeignKey, ManyToManyField, Model, TextField, UUIDField, IntegerField
|
||||
from django.db.models import BigAutoField, BooleanField, CASCADE, CharField, DateTimeField, ForeignKey, ManyToManyField, Model, TextField, UUIDField, IntegerField, FileField
|
||||
from django.db.models.signals import post_delete
|
||||
from django.dispatch import receiver
|
||||
from apps.users.mixins import TimeStampMixin
|
||||
from apps.users.models import User
|
||||
|
||||
|
|
@ -97,3 +99,39 @@ class RoleMembership(TimeStampMixin, Model):
|
|||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user.full_name} - {self.role.name}"
|
||||
|
||||
class TrainingFile(TimeStampMixin, Model):
|
||||
|
||||
ALLOWED_EXTENSIONS = ('txt', 'pdf', 'md', 'csv', 'json', 'docx', 'doc')
|
||||
|
||||
id = BigAutoField(primary_key = True)
|
||||
uuid = UUIDField(default = uuid4, unique = True, editable = False)
|
||||
organization = ForeignKey(Organization, on_delete = CASCADE, related_name = "training_files")
|
||||
uploaded_by = ForeignKey(User, on_delete = CASCADE, related_name = "uploaded_training_files")
|
||||
|
||||
file = FileField(upload_to = 'training_files/%Y/%m/%d/')
|
||||
file_name = CharField(max_length = 255)
|
||||
file_size = IntegerField()
|
||||
file_type = CharField(max_length = 50)
|
||||
|
||||
description = TextField(blank = True, default = '')
|
||||
is_processed = BooleanField(default = False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Training File")
|
||||
verbose_name_plural = _("Training Files")
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.file_name} - {self.organization.name}"
|
||||
|
||||
|
||||
@receiver(post_delete, sender=TrainingFile)
|
||||
def delete_training_file_on_delete(sender, instance, **kwargs):
|
||||
if instance.file:
|
||||
try:
|
||||
import os
|
||||
if os.path.isfile(instance.file.path):
|
||||
os.remove(instance.file.path)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from rest_framework.serializers import ModelSerializer, SerializerMethodField, IntegerField
|
||||
from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership
|
||||
from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership, TrainingFile
|
||||
from apps.users.serializers import UserSerializer
|
||||
|
||||
class OrganizationSerializer(ModelSerializer):
|
||||
|
|
@ -71,3 +71,47 @@ class RoleSerializer(ModelSerializer):
|
|||
|
||||
def get_member_count(self, obj):
|
||||
return obj.memberships.count()
|
||||
|
||||
class TrainingFileSerializer(ModelSerializer):
|
||||
uploaded_by = UserSerializer(read_only = True)
|
||||
file_url = SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TrainingFile
|
||||
fields = ['id', 'uuid', 'organization', 'uploaded_by', 'file', 'file_name', 'file_size', 'file_type', 'description', 'is_processed', 'file_url', 'created_at', 'updated_at']
|
||||
read_only_fields = ['uuid', 'uploaded_by', 'file_size', 'file_type', 'is_processed', 'created_at', 'updated_at', 'organization']
|
||||
|
||||
def get_file_url(self, obj):
|
||||
request = self.context.get('request')
|
||||
if request and obj.file:
|
||||
return request.build_absolute_uri(obj.file.url)
|
||||
return None
|
||||
|
||||
def validate_file(self, value):
|
||||
"""Validate that uploaded file is a text-based file."""
|
||||
if not value:
|
||||
raise ValueError('File is required')
|
||||
|
||||
import os
|
||||
file_extension = os.path.splitext(value.name)[1][1:].lower()
|
||||
|
||||
if file_extension not in TrainingFile.ALLOWED_EXTENSIONS:
|
||||
raise ValueError(
|
||||
f'File type ".{file_extension}" is not allowed. '
|
||||
f'Allowed types: {", ".join(TrainingFile.ALLOWED_EXTENSIONS)}'
|
||||
)
|
||||
|
||||
max_size = 50 * 1024 * 1024
|
||||
if value.size > max_size:
|
||||
raise ValueError(f'File size must not exceed 50MB. Current size: {value.size / 1024 / 1024:.2f}MB')
|
||||
|
||||
return value
|
||||
|
||||
def create(self, validated_data):
|
||||
file_obj = validated_data.get('file')
|
||||
if file_obj:
|
||||
validated_data['file_size'] = file_obj.size
|
||||
import os
|
||||
file_extension = os.path.splitext(file_obj.name)[1][1:].lower()
|
||||
validated_data['file_type'] = file_extension
|
||||
return super().create(validated_data)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership
|
||||
from apps.orgs.serializers import ModelSerializer, OrganizationSerializer, OrganizationMembershipSerializer, OrganizationInvitationSerializer, RoleSerializer, RoleMembershipSerializer
|
||||
from apps.orgs.models import Organization, OrganizationMembership, OrganizationInvitation, Role, RoleMembership, TrainingFile
|
||||
from apps.orgs.serializers import ModelSerializer, OrganizationSerializer, OrganizationMembershipSerializer, OrganizationInvitationSerializer, RoleSerializer, RoleMembershipSerializer, TrainingFileSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
|
|
@ -9,6 +9,7 @@ from rest_framework.decorators import action
|
|||
from django.utils import timezone
|
||||
from apps.users.models import User
|
||||
from apps.users.serializers import UserSerializer
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
|
||||
class OrganizationViewSet(ModelViewSet):
|
||||
|
|
@ -189,4 +190,51 @@ class OrganizationViewSet(ModelViewSet):
|
|||
memberships = RoleMembership.objects.filter(role = role)
|
||||
serializer = RoleMembershipSerializer(memberships, many = True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get', 'post'], url_path='training-file')
|
||||
def training_files(self, request, uuid = None):
|
||||
organization = self.get_object()
|
||||
|
||||
if request.method == 'GET':
|
||||
training_files = TrainingFile.objects.filter(organization=organization)
|
||||
serializer = TrainingFileSerializer(training_files, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
if not (organization.owner == request.user or
|
||||
organization.memberships.filter(user=request.user).exists()):
|
||||
return Response(
|
||||
{'error': 'You do not have permission to upload files to this organization'},
|
||||
status=HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
serializer = TrainingFileSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(uploaded_by=request.user, organization=organization)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['get', 'delete'], url_path='training-file/(?P<file_uuid>[0-9a-f-]{36})')
|
||||
def training_file_detail(self, request, uuid = None, file_uuid = None):
|
||||
organization = self.get_object()
|
||||
|
||||
try:
|
||||
training_file = TrainingFile.objects.get(uuid=file_uuid, organization=organization)
|
||||
except TrainingFile.DoesNotExist:
|
||||
return Response({'error': 'Training file not found'}, status=HTTP_404_NOT_FOUND)
|
||||
|
||||
if request.method == 'GET':
|
||||
serializer = TrainingFileSerializer(training_file)
|
||||
return Response(serializer.data)
|
||||
|
||||
if not (training_file.uploaded_by == request.user or
|
||||
training_file.organization.owner == request.user or
|
||||
request.user.is_manager):
|
||||
return Response(
|
||||
{'error': 'You do not have permission to delete this file'},
|
||||
status=HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
file_name = training_file.file_name
|
||||
training_file.delete()
|
||||
return Response({'message': f'File "{file_name}" successfully deleted'})
|
||||
|
||||
|
|
|
|||
11
src/App.vue
11
src/App.vue
|
|
@ -86,7 +86,7 @@ const onSelect = (info: SimpleMenuInfo) => {
|
|||
}
|
||||
}
|
||||
if (found && found.path && route.path !== found.path) {
|
||||
const selectedOrgUuid = userStore.selectedOrganizationUuid
|
||||
const selectedOrgUuid = userStore.userSelectedOrganization?.uuid
|
||||
if (found.path === '/organization' && selectedOrgUuid) {
|
||||
router.push(`/organization/${selectedOrgUuid}`)
|
||||
} else {
|
||||
|
|
@ -161,19 +161,20 @@ const user = userStore
|
|||
<Space>
|
||||
<template v-if="user.isAuthenticated">
|
||||
<Select
|
||||
v-if="user.organizations && user.organizations.length > 0"
|
||||
:value="user.selectedOrganizationUuid ?? undefined"
|
||||
v-if="user.userJoinedOrganizations && user.userJoinedOrganizations.length > 0"
|
||||
:value="user.userSelectedOrganization?.uuid ?? undefined"
|
||||
@change="
|
||||
(val) => {
|
||||
const org = user.userJoinedOrganizations.find(o => o.uuid === val)
|
||||
user.setSelectedOrganization &&
|
||||
user.setSelectedOrganization(val == null ? null : String(val))
|
||||
user.setSelectedOrganization(org ?? null)
|
||||
}
|
||||
"
|
||||
style="min-width: 220px; margin-right: 0.5rem"
|
||||
placeholder="Select organization"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="o in user.organizations"
|
||||
v-for="o in user.userJoinedOrganizations"
|
||||
:key="o.uuid"
|
||||
:value="o.uuid"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ export const API = {
|
|||
`/api/organization/${orgUuid}/create-invite/?max_uses=${max_uses}`,
|
||||
organizationJoin: (token: string) => `/api/organization/join/${token}/`,
|
||||
organizationLeave: (orgUuid: string) => `/api/organization/${orgUuid}/leave/`,
|
||||
organizationTrainingFiles: (orgUuid: string) => `/api/organization/${orgUuid}/training-file/`,
|
||||
organizationTrainingFile: (orgUuid: string, fileUuid: string) =>
|
||||
`/api/organization/${orgUuid}/training-file/${fileUuid}/`,
|
||||
agents: () => '/api/agent/',
|
||||
agent: (id: string) => `/api/agent/${id}/`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,16 @@ export const useAgentStore = defineStore('agent', () => {
|
|||
)
|
||||
}
|
||||
|
||||
const startFineTune = (inputData?: Record<string, unknown>) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
action: 'fine_tune',
|
||||
input_data: inputData ?? {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const stopAgent = (executionId?: string) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(
|
||||
|
|
@ -132,6 +142,7 @@ export const useAgentStore = defineStore('agent', () => {
|
|||
connect,
|
||||
disconnect,
|
||||
startAgent,
|
||||
startFineTune,
|
||||
stopAgent,
|
||||
resetLog,
|
||||
lastExecutionId,
|
||||
|
|
|
|||
|
|
@ -34,3 +34,18 @@ export interface InviteToken {
|
|||
max_uses?: number
|
||||
uses?: number
|
||||
}
|
||||
export interface TrainingFile {
|
||||
id: number
|
||||
uuid: string
|
||||
organization: string
|
||||
uploaded_by: User
|
||||
file: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
file_type: string
|
||||
description: string
|
||||
is_processed: boolean
|
||||
file_url: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
|
@ -84,6 +84,16 @@ const startAgent = () => {
|
|||
message.success('Agent execution started')
|
||||
}
|
||||
|
||||
const startFineTune = () => {
|
||||
if (!agentStore.isConnected) {
|
||||
message.error('WebSocket not connected')
|
||||
return
|
||||
}
|
||||
|
||||
agentStore.startFineTune()
|
||||
message.success('Fine-tune started')
|
||||
}
|
||||
|
||||
const stopAgent = () => {
|
||||
agentStore.stopAgent(agentStore.lastExecutionId || undefined)
|
||||
message.success('Agent stop requested')
|
||||
|
|
@ -138,6 +148,9 @@ onUnmounted(() => {
|
|||
<Button type="primary" :disabled="isRunning || !isConnected" @click="startAgent">
|
||||
Run Agent
|
||||
</Button>
|
||||
<Button :disabled="isRunning || !isConnected" @click="startFineTune">
|
||||
Fine-Tune
|
||||
</Button>
|
||||
<Button danger :disabled="!isRunning" @click="stopAgent">
|
||||
Stop Agent
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -163,16 +163,8 @@ onMounted(async () => {
|
|||
<div class="page">
|
||||
<Spin :spinning="loading" tip="Loading organization...">
|
||||
<Card v-if="organization" class="panel" :bordered="false">
|
||||
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
|
||||
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
"
|
||||
>
|
||||
<div class="header">
|
||||
<Typography.Title :level="2">Manage {{ organization.name }}</Typography.Title>
|
||||
<Button type="default" @click="router.push(`/organization/${orgId}`)">
|
||||
Back to Organization
|
||||
</Button>
|
||||
|
|
@ -181,7 +173,7 @@ onMounted(async () => {
|
|||
<Tabs>
|
||||
<Tabs.TabPane key="details" tab="Details">
|
||||
<div class="section">
|
||||
<Typography.Title :level="4">Description</Typography.Title>
|
||||
<Typography.Title :level="4" style="color: #ffffff !important">Description</Typography.Title>
|
||||
<div v-if="!editingDescription">
|
||||
<Typography.Paragraph>
|
||||
{{ organization.description || 'No description provided' }}
|
||||
|
|
@ -207,7 +199,7 @@ onMounted(async () => {
|
|||
<Tabs.TabPane key="members" tab="Members">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<Typography.Title :level="4">
|
||||
<Typography.Title :level="4" style="color: #ffffff !important">
|
||||
Members ({{ members.length }})
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
|
@ -239,7 +231,7 @@ onMounted(async () => {
|
|||
<Tabs.TabPane key="invites" tab="Invites">
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<Typography.Title :level="4">Invite Tokens</Typography.Title>
|
||||
<Typography.Title :level="4" style="color: #ffffff !important">Invite Tokens</Typography.Title>
|
||||
<Space>
|
||||
<InputNumber v-model:value="newInviteMaxUses" :min="1" />
|
||||
<Button type="primary" @click="createInvite">
|
||||
|
|
@ -289,7 +281,7 @@ onMounted(async () => {
|
|||
|
||||
<Tabs.TabPane key="Roles" tab="Roles">
|
||||
<div class="section">
|
||||
<Typography.Title :level="4">
|
||||
<Typography.Title :level="4" style="color: #ffffff !important">
|
||||
Roles ({{ Roles.length }})
|
||||
</Typography.Title>
|
||||
|
||||
|
|
@ -341,6 +333,13 @@ onMounted(async () => {
|
|||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
|
@ -357,8 +356,25 @@ onMounted(async () => {
|
|||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.invite-item ::v-deep .ant-tag,
|
||||
.invite-item ::v-deep .ant-tag * {
|
||||
color: #000000 !important;
|
||||
:deep(.ant-typography),
|
||||
:deep(.ant-typography p),
|
||||
:deep(.ant-typography span),
|
||||
:deep(.ant-typography h4),
|
||||
:deep(.ant-list-item),
|
||||
:deep(.ant-list-item-meta-title),
|
||||
:deep(.ant-list-item-meta-description),
|
||||
:deep(.ant-tabs-tab),
|
||||
:deep(.ant-input-number),
|
||||
:deep(.ant-input-number-input) {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
:deep(.ant-typography-secondary) {
|
||||
color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
:deep(.ant-input-number) {
|
||||
background: #111827;
|
||||
border-color: #334155;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider } from 'ant-design-vue'
|
||||
import { Card, Typography, Button, List, Space, Spin, message, Tag, Divider, Upload, Modal, Table } from 'ant-design-vue'
|
||||
import { apiClient, isAxiosError, API } from '../router/api'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
import type { Role, Organization } from '../types/organization'
|
||||
import { InboxOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import type { Role, Organization, TrainingFile } from '../types/organization'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
|
@ -13,7 +14,10 @@ const orgId = route.params.id as string
|
|||
const organization = ref<Organization | null>(null)
|
||||
const roles = ref<Role[]>([])
|
||||
const members = ref<Array<{ user: { id: number }; role: string }>>([])
|
||||
const trainingFiles = ref<TrainingFile[]>([])
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const showUploadModal = ref(false)
|
||||
const auth = useUserStore()
|
||||
|
||||
const isManager = computed(() => {
|
||||
|
|
@ -131,11 +135,189 @@ const selectRole = async (roleUuid: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
const fetchTrainingFiles = async () => {
|
||||
if (!organization.value?.uuid) return
|
||||
try {
|
||||
const response = await apiClient.get<TrainingFile[]>(
|
||||
API.organizationTrainingFiles(organization.value.uuid),
|
||||
)
|
||||
trainingFiles.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch training files:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
const allowedExtensions = ['txt', 'pdf', 'md', 'csv', 'json', 'docx', 'doc']
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!fileExtension || !allowedExtensions.includes(fileExtension)) {
|
||||
message.error(
|
||||
`File type ".${fileExtension}" is not allowed. Allowed types: ${allowedExtensions.join(', ')}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
const maxSize = 50 * 1024 * 1024 // 50MB
|
||||
if (file.size > maxSize) {
|
||||
message.error(`File size must not exceed 50MB. Current size: ${(file.size / 1024 / 1024).toFixed(2)}MB`)
|
||||
return false
|
||||
}
|
||||
|
||||
return false // Return false to prevent automatic upload
|
||||
}
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const fileDescription = ref('')
|
||||
|
||||
const handleFileSelected = (file: File) => {
|
||||
selectedFile.value = file
|
||||
}
|
||||
|
||||
const handleFileUploadClick = async () => {
|
||||
if (!selectedFile.value) {
|
||||
message.error('Please select a file to upload')
|
||||
return
|
||||
}
|
||||
|
||||
await handleFileUpload(selectedFile.value, fileDescription.value)
|
||||
selectedFile.value = null
|
||||
fileDescription.value = ''
|
||||
}
|
||||
|
||||
const handleFileUpload = async (file: File, description: string = '') => {
|
||||
if (!organization.value?.uuid) {
|
||||
message.error('Organization not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('file_name', file.name)
|
||||
formData.append('description', description)
|
||||
|
||||
const response = await apiClient.post<TrainingFile>(
|
||||
API.organizationTrainingFiles(organization.value.uuid),
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
message.success(`File "${file.name}" uploaded successfully`)
|
||||
trainingFiles.value.unshift(response.data)
|
||||
showUploadModal.value = false
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error)
|
||||
if (isAxiosError(error)) {
|
||||
const errorMsg = error.response?.data?.error || error.response?.data?.file?.[0] || 'Failed to upload file'
|
||||
message.error(errorMsg)
|
||||
} else {
|
||||
message.error('Failed to upload file')
|
||||
}
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFile = async (uuid: string, fileName: string) => {
|
||||
if (!organization.value?.uuid) {
|
||||
message.error('Organization not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: 'Delete File',
|
||||
content: `Are you sure you want to delete "${fileName}"? This action cannot be undone.`,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await apiClient.delete(API.organizationTrainingFile(organization.value!.uuid, uuid))
|
||||
message.success('File deleted successfully')
|
||||
trainingFiles.value = trainingFiles.value.filter((f) => f.uuid !== uuid)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file:', error)
|
||||
message.error('Failed to delete file')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const trainingFileColumns = [
|
||||
{
|
||||
title: 'File Name',
|
||||
dataIndex: 'file_name',
|
||||
key: 'file_name',
|
||||
},
|
||||
{
|
||||
title: 'Uploaded By',
|
||||
key: 'uploaded_by',
|
||||
customRender: ({ record }: { record: TrainingFile }) => {
|
||||
if (!record.uploaded_by) return '-'
|
||||
const full_name = `${record.uploaded_by.first_name} ${record.uploaded_by.last_name}`
|
||||
return full_name
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
dataIndex: 'file_size',
|
||||
key: 'file_size',
|
||||
customRender: ({ value }: { value: number }) => formatFileSize(value || 0),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'is_processed',
|
||||
key: 'is_processed',
|
||||
customRender: ({ value }: { value: boolean }) =>
|
||||
h(Tag, { color: value ? 'success' : 'processing' }, () => value ? 'Processed' : 'Processing'),
|
||||
},
|
||||
{
|
||||
title: 'Uploaded',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
customRender: ({ value }: { value: string }) => new Date(value).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
title: 'Action',
|
||||
key: 'action',
|
||||
customRender: ({ record }: { record: TrainingFile }) => {
|
||||
if (isManager.value || auth.user?.id === record.uploaded_by?.id) {
|
||||
return h(
|
||||
Button,
|
||||
{
|
||||
danger: true,
|
||||
size: 'small',
|
||||
icon: h(DeleteOutlined),
|
||||
onClick: () => deleteFile(record.uuid, record.file_name),
|
||||
},
|
||||
() => 'Delete',
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrganization().then(async () => {
|
||||
await fetchMembers()
|
||||
await fetchRoles()
|
||||
await fetchUserRoleMemberships()
|
||||
await fetchTrainingFiles()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
|
@ -179,6 +361,35 @@ onMounted(() => {
|
|||
|
||||
<Divider />
|
||||
|
||||
<Typography.Title :level="4" class="section-title">
|
||||
Training Files
|
||||
</Typography.Title>
|
||||
|
||||
<div style="margin-bottom: 1rem">
|
||||
<Button
|
||||
type="primary"
|
||||
@click="showUploadModal = true"
|
||||
style="margin-bottom: 1rem"
|
||||
>
|
||||
Upload Training File
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="trainingFiles.length > 0">
|
||||
<Table
|
||||
:columns="trainingFileColumns"
|
||||
:data-source="trainingFiles"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
:row-key="(record: TrainingFile) => record.uuid"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<Typography.Paragraph v-else type="secondary">
|
||||
No training files uploaded yet.
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography.Title :level="4" class="section-title">
|
||||
Available Roles
|
||||
</Typography.Title>
|
||||
|
|
@ -189,10 +400,11 @@ onMounted(() => {
|
|||
<List.Item class="role-item">
|
||||
<List.Item.Meta :title="item.name" />
|
||||
<Space>
|
||||
<Tag>{{ item.member_count ?? 0 }} members</Tag>
|
||||
<Tag color="cyan">{{ item.member_count ?? 0 }} members</Tag>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
:disabled="isManager"
|
||||
@click="router.push(`/onboarding/${item.uuid}`)"
|
||||
>
|
||||
Start Onboarding
|
||||
|
|
@ -201,6 +413,7 @@ onMounted(() => {
|
|||
v-if="item.uuid && !isRoleJoined(item.uuid)"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="isManager"
|
||||
@click="selectRole(item.uuid)"
|
||||
>
|
||||
Join Role
|
||||
|
|
@ -216,6 +429,41 @@ onMounted(() => {
|
|||
</Typography.Paragraph>
|
||||
</Card>
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
v-model:open="showUploadModal"
|
||||
title="Upload Training File"
|
||||
width="600px"
|
||||
ok-text="Upload"
|
||||
cancel-text="Cancel"
|
||||
:ok-button-props="{ loading: uploading, disabled: !selectedFile }"
|
||||
@ok="handleFileUploadClick"
|
||||
@cancel="showUploadModal = false"
|
||||
>
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem">
|
||||
<Typography.Text>
|
||||
Supported formats: <strong>txt, pdf, md, csv, json, docx, doc</strong> (Max 50MB)
|
||||
</Typography.Text>
|
||||
<Upload.Dragger
|
||||
accept=".txt,.pdf,.md,.csv,.json,.docx,.doc"
|
||||
:before-upload="(file) => {
|
||||
beforeUpload(file)
|
||||
handleFileSelected(file)
|
||||
return false
|
||||
}"
|
||||
:multiple="false"
|
||||
:auto-upload="false"
|
||||
>
|
||||
<p class="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p class="ant-upload-text">Click or drag file to this area to upload</p>
|
||||
<p class="ant-upload-hint">
|
||||
{{ selectedFile ? selectedFile.name : 'Single file upload' }}
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue