diff --git a/apps/mlstore/admin.py b/apps/mlstore/admin.py index 770593c..5f23c6e 100644 --- a/apps/mlstore/admin.py +++ b/apps/mlstore/admin.py @@ -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,) diff --git a/apps/mlstore/migrations/0001_initial.py b/apps/mlstore/migrations/0001_initial.py index d51b45e..09fa64d 100644 --- a/apps/mlstore/migrations/0001_initial.py +++ b/apps/mlstore/migrations/0001_initial.py @@ -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={ diff --git a/apps/mlstore/migrations/0002_agentmodel_path.py b/apps/mlstore/migrations/0002_agentmodel_path.py deleted file mode 100644 index 14649a4..0000000 --- a/apps/mlstore/migrations/0002_agentmodel_path.py +++ /dev/null @@ -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), - ), - ] diff --git a/apps/mlstore/models.py b/apps/mlstore/models.py index b1b30b5..846bb09 100644 --- a/apps/mlstore/models.py +++ b/apps/mlstore/models.py @@ -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'] diff --git a/apps/mlstore/serializers.py b/apps/mlstore/serializers.py index 13fb140..8c63f3a 100644 --- a/apps/mlstore/serializers.py +++ b/apps/mlstore/serializers.py @@ -18,6 +18,7 @@ class AgentSerializer(ModelSerializer): 'id', 'uuid', 'model', + 'organization', 'status', 'description', 'started_at', diff --git a/apps/mlstore/services.py b/apps/mlstore/services.py index 1baccad..6b5e501 100644 --- a/apps/mlstore/services.py +++ b/apps/mlstore/services.py @@ -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: diff --git a/apps/mlstore/tasks.py b/apps/mlstore/tasks.py index 0a738f9..89c4b54 100644 --- a/apps/mlstore/tasks.py +++ b/apps/mlstore/tasks.py @@ -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) diff --git a/apps/orgs/admin.py b/apps/orgs/admin.py index 8541fd2..7fdd7c1 100644 --- a/apps/orgs/admin.py +++ b/apps/orgs/admin.py @@ -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') \ No newline at end of file + 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') \ No newline at end of file diff --git a/apps/orgs/migrations/0001_initial.py b/apps/orgs/migrations/0001_initial.py index 26875b2..ee886b8 100644 --- a/apps/orgs/migrations/0001_initial.py +++ b/apps/orgs/migrations/0001_initial.py @@ -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'], + }, + ), ] diff --git a/apps/orgs/models.py b/apps/orgs/models.py index 7c8d77d..782d21f 100644 --- a/apps/orgs/models.py +++ b/apps/orgs/models.py @@ -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 diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index c59d39c..6410aae 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -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) \ No newline at end of file diff --git a/apps/orgs/viewsets.py b/apps/orgs/viewsets.py index a5e304f..e107924 100644 --- a/apps/orgs/viewsets.py +++ b/apps/orgs/viewsets.py @@ -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[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'}) diff --git a/src/App.vue b/src/App.vue index 22139fd..4bc48e1 100644 --- a/src/App.vue +++ b/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