diff --git a/apps/agents/migrations/0002_alter_agent_options_alter_agentexecution_options.py b/apps/agents/migrations/0002_alter_agent_options_alter_agentexecution_options.py new file mode 100644 index 0000000..2b8f6a6 --- /dev/null +++ b/apps/agents/migrations/0002_alter_agent_options_alter_agentexecution_options.py @@ -0,0 +1,21 @@ +# 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'}, + ), + ] diff --git a/apps/domains/admin.py b/apps/domains/admin.py index 2885f9f..7d850fa 100644 --- a/apps/domains/admin.py +++ b/apps/domains/admin.py @@ -1,38 +1,65 @@ from django.contrib import admin -from apps.domains.models import Domain, Organisation, Dataset +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', 'uuid') - search_fields = ('name',) - readonly_fields = ('uuid',) - fieldsets = ( - (None, {'fields': ('name', 'uuid')}), - ('Description', {'fields': ('description',)}), - ) + 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(Organisation) -class OrganisationAdmin(admin.ModelAdmin): - list_display = ('name', 'uuid', 'created_at', 'updated_at') - search_fields = ('name',) - readonly_fields = ('uuid', 'created_at', 'updated_at') - fieldsets = ( - (None, {'fields': ('name', 'uuid')}), - ('Relations', {'fields': ('managers', 'employees', 'domains')}), - ('Dates', {'fields': ('created_at', 'updated_at')}), - ) +@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')}), - ) + 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')}), + ) + diff --git a/apps/domains/migrations/0002_alter_domain_options_domainmembership_domain_members_and_more.py b/apps/domains/migrations/0002_alter_domain_options_domainmembership_domain_members_and_more.py new file mode 100644 index 0000000..a31f0ed --- /dev/null +++ b/apps/domains/migrations/0002_alter_domain_options_domainmembership_domain_members_and_more.py @@ -0,0 +1,105 @@ +# 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', + ), + ] diff --git a/apps/domains/models.py b/apps/domains/models.py index 05160f2..2014b40 100644 --- a/apps/domains/models.py +++ b/apps/domains/models.py @@ -6,31 +6,108 @@ from django.db.models import ( 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 = "") + 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 Organisation(TimeStampMixin, Model): - name = CharField(max_length = 255, unique = True) - uuid = UUIDField(default = uuid4, editable = False, unique = True) - managers = ForeignKey(User, on_delete = CASCADE, related_name = "managed_organisations") - employees = ForeignKey(User, on_delete = CASCADE, related_name = "organisations") - domains = ForeignKey(Domain, on_delete = CASCADE, related_name = "organisations") +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 self.name + return f"{self.user.full_name} - {self.domain.name}" class Dataset(TimeStampMixin, Model): diff --git a/apps/domains/serializers.py b/apps/domains/serializers.py index 2d3184d..bad38e7 100644 --- a/apps/domains/serializers.py +++ b/apps/domains/serializers.py @@ -1,19 +1,79 @@ +from rest_framework import serializers from rest_framework.serializers import ModelSerializer -from apps.domains.models import Domain, Organisation, Dataset +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', 'name', 'description', 'uuid'] + fields = ['id', 'uuid', 'name', 'description', 'organization', 'organization_id', 'member_count'] + read_only_fields = ['uuid'] - -class OrganisationSerializer(ModelSerializer): - - class Meta: - model = Organisation - fields = ['id', 'name', 'managers', 'employees', 'domains', 'uuid', 'created_at', 'updated_at'] + def get_member_count(self, obj): + return obj.memberships.count() class DatasetSerializer(ModelSerializer): diff --git a/apps/domains/viewsets.py b/apps/domains/viewsets.py index 1853054..bc7fa11 100644 --- a/apps/domains/viewsets.py +++ b/apps/domains/viewsets.py @@ -1,28 +1,246 @@ from rest_framework.viewsets import ModelViewSet -from rest_framework.permissions import IsAuthenticatedOrReadOnly -from apps.domains.models import Domain, Organisation, Dataset -from apps.domains.serializers import DomainSerializer, OrganisationSerializer, DatasetSerializer +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[^/.]+)') + 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[^/.]+)') + 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[^/.]+)') + 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[^/.]+)/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[^/.]+)/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' - -class OrganisationViewSet(ModelViewSet): - - queryset = Organisation.objects.all() - serializer_class = OrganisationSerializer - 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' + diff --git a/config/__pycache__/__init__.cpython-313.pyc b/config/__pycache__/__init__.cpython-313.pyc index b7eb80b..dcdc928 100644 Binary files a/config/__pycache__/__init__.cpython-313.pyc and b/config/__pycache__/__init__.cpython-313.pyc differ diff --git a/config/__pycache__/settings.cpython-313.pyc b/config/__pycache__/settings.cpython-313.pyc index cde58dd..b215aa6 100644 Binary files a/config/__pycache__/settings.cpython-313.pyc and b/config/__pycache__/settings.cpython-313.pyc differ diff --git a/config/api.py b/config/api.py index 171383c..90f4ae3 100644 --- a/config/api.py +++ b/config/api.py @@ -1,15 +1,16 @@ from rest_framework.routers import DefaultRouter -from apps.domains.viewsets import DomainViewSet, OrganisationViewSet, DatasetViewSet +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'domain', DomainViewSet, basename = 'domain') -router.register(r'organisation', OrganisationViewSet, basename = 'organisation') -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') +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 diff --git a/src/app/App.vue b/src/app/App.vue index ab1fbd4..d1292eb 100644 --- a/src/app/App.vue +++ b/src/app/App.vue @@ -3,17 +3,18 @@ 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, + 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'; @@ -23,193 +24,206 @@ 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: '/', + 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 - ) + navItems.filter((item) => + item.roles ? authStore.hasRole(item.roles) : true + ) ); const selectedKeys = computed(() => { - const match = visibleNavItems.value.find((item) => - route.path.startsWith(item.key) - ); - return match ? [match.key] : []; + 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) router.push(item.path); + 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('/'); + await authStore.logout(); + router.push('/'); }; onMounted(() => { - authStore.fetchSession(); + authStore.fetchSession(); }); diff --git a/src/router/index.ts b/src/router/index.ts index b77736f..c0d2828 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -75,6 +75,24 @@ const router = createRouter({ component: () => import('../views/Resources.vue'), meta: { requiresAuth: true }, }, + { + path: '/organizations/:id', + name: 'organization-view', + component: () => import('../views/OrganizationView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/organizations/:id/manage', + name: 'organization-manage', + component: () => import('../views/OrganizationManage.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/invite/:token', + name: 'invite-accept', + component: () => import('../views/InviteAccept.vue'), + meta: { requiresAuth: true }, + }, ], }); diff --git a/src/views/AgentDetail.vue b/src/views/AgentDetail.vue index 7244406..560bd0a 100644 --- a/src/views/AgentDetail.vue +++ b/src/views/AgentDetail.vue @@ -2,15 +2,15 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'; import { useRoute } from 'vue-router'; import { - Card, - Typography, - Button, - List, - Space, - Spin, - Input, - message, - Tag, + Card, + Typography, + Button, + List, + Space, + Spin, + Input, + message, + Tag, } from 'ant-design-vue'; import { useAgentStore } from '../stores/agentStore'; import { apiClient, isAxiosError } from '../lib/api'; @@ -25,387 +25,387 @@ const agentId = route.params.id as string; console.log('Agent ID:', agentId); if (!agentId) { - console.error('ERROR: No agent ID in route params'); + console.error('ERROR: No agent ID in route params'); } const agent = ref>({ - id: agentId, - name: 'Loading...', - description: '', - status: 'idle', + id: agentId, + name: 'Loading...', + description: '', + status: 'idle', }); console.log('Initial agent state:', agent.value); const queryInput = ref(''); const isRunning = computed(() => { - console.log( - 'isRunning computed - executionStatus:', - agentStore.executionStatus - ); - return agentStore.executionStatus === 'running'; + console.log( + 'isRunning computed - executionStatus:', + agentStore.executionStatus + ); + return agentStore.executionStatus === 'running'; }); const isConnected = computed(() => { - console.log('isConnected computed - isConnected:', agentStore.isConnected); - return agentStore.isConnected ?? false; + console.log('isConnected computed - isConnected:', agentStore.isConnected); + return agentStore.isConnected ?? false; }); const agentResponse = computed(() => { - const completedEvent = agentStore.eventLog?.find( - (event) => event.type === 'completed' - ); - if (completedEvent?.content && typeof completedEvent.content === 'object') { - const output = completedEvent.content as Record; - return (output.response as string) || null; - } - return null; + const completedEvent = agentStore.eventLog?.find( + (event) => event.type === 'completed' + ); + if (completedEvent?.content && typeof completedEvent.content === 'object') { + const output = completedEvent.content as Record; + return (output.response as string) || null; + } + return null; }); const statusColor = (status: string) => { - const colors: Record = { - idle: 'default', - running: 'processing', - completed: 'success', - failed: 'error', - stopped: 'warning', - }; - return colors[status] || 'default'; + const colors: Record = { + idle: 'default', + running: 'processing', + completed: 'success', + failed: 'error', + stopped: 'warning', + }; + return colors[status] || 'default'; }; const fetchAgent = async () => { - console.log('Fetching agent details for ID:', agentId); - try { - const response = await apiClient.get>( - `/api/agent/${agentId}/` - ); - agent.value = response.data; - console.log('Agent fetched successfully:', agent.value); - } catch (error) { - console.error('ERROR - Failed to fetch agent:', error); - if (isAxiosError(error)) { - console.error('Axios error details:', { - status: error.response?.status, - data: error.response?.data, - message: error.message, - }); - } - message.error('Failed to load agent details'); - } + console.log('Fetching agent details for ID:', agentId); + try { + const response = await apiClient.get>( + `/api/agent/${agentId}/` + ); + agent.value = response.data; + console.log('Agent fetched successfully:', agent.value); + } catch (error) { + console.error('ERROR - Failed to fetch agent:', error); + if (isAxiosError(error)) { + console.error('Axios error details:', { + status: error.response?.status, + data: error.response?.data, + message: error.message, + }); + } + message.error('Failed to load agent details'); + } }; const startAgent = () => { - console.log('Starting agent execution'); + console.log('Starting agent execution'); - if (!agentStore.isConnected) { - console.warn('WARNING: WebSocket not connected'); - console.log('Connection state:', { - isConnected: agentStore.isConnected, - }); - message.error('WebSocket not connected'); - return; - } + if (!agentStore.isConnected) { + console.warn('WARNING: WebSocket not connected'); + console.log('Connection state:', { + isConnected: agentStore.isConnected, + }); + message.error('WebSocket not connected'); + return; + } - if (!queryInput.value.trim()) { - message.error('Please enter a query'); - return; - } + if (!queryInput.value.trim()) { + message.error('Please enter a query'); + return; + } - try { - const data = { - query: queryInput.value.trim(), - }; - console.log('Sending data:', data); + try { + const data = { + query: queryInput.value.trim(), + }; + console.log('Sending data:', data); - console.log('Calling startAgent on store'); - agentStore.startAgent(data); - console.log('Agent execution initiated'); - message.success('Agent execution started'); - } catch (error) { - console.error('ERROR - Failed to start agent:', error); - message.error('Failed to start agent'); - } + console.log('Calling startAgent on store'); + agentStore.startAgent(data); + console.log('Agent execution initiated'); + message.success('Agent execution started'); + } catch (error) { + console.error('ERROR - Failed to start agent:', error); + message.error('Failed to start agent'); + } }; const stopAgent = () => { - console.log('Stopping agent execution'); + console.log('Stopping agent execution'); - try { - console.log('Calling stopAgent on store'); - agentStore.stopAgent(); - console.log('Agent stop signal sent'); - message.success('Agent stop requested'); - } catch (error) { - console.error('ERROR - Failed to stop agent:', error); - } + try { + console.log('Calling stopAgent on store'); + agentStore.stopAgent(); + console.log('Agent stop signal sent'); + message.success('Agent stop requested'); + } catch (error) { + console.error('ERROR - Failed to stop agent:', error); + } }; onMounted(() => { - console.log('Component mounted'); - console.log('Lifecycle: onMounted - starting initialization'); + console.log('Component mounted'); + console.log('Lifecycle: onMounted - starting initialization'); - fetchAgent(); + fetchAgent(); - console.log('Attempting WebSocket connection for agent:', agentId); - try { - agentStore.connect(agentId); - console.log('WebSocket connection initiated'); - } catch (error) { - console.error('ERROR - Failed to connect WebSocket:', error); - } + console.log('Attempting WebSocket connection for agent:', agentId); + try { + agentStore.connect(agentId); + console.log('WebSocket connection initiated'); + } catch (error) { + console.error('ERROR - Failed to connect WebSocket:', error); + } }); onUnmounted(() => { - console.log('Component unmounted'); - console.log('Lifecycle: onUnmounted - cleaning up'); + console.log('Component unmounted'); + console.log('Lifecycle: onUnmounted - cleaning up'); - try { - console.log('Disconnecting WebSocket'); - agentStore.disconnect(); - console.log('WebSocket disconnected successfully'); - } catch (error) { - console.error('ERROR - Failed to disconnect WebSocket:', error); - } + try { + console.log('Disconnecting WebSocket'); + agentStore.disconnect(); + console.log('WebSocket disconnected successfully'); + } catch (error) { + console.error('ERROR - Failed to disconnect WebSocket:', error); + } }); diff --git a/src/views/Agents.vue b/src/views/Agents.vue index 88a7b26..2037215 100644 --- a/src/views/Agents.vue +++ b/src/views/Agents.vue @@ -4,11 +4,11 @@ import { List, Typography, Button, Card, Spin, message } from 'ant-design-vue'; import { apiClient } from '../lib/api'; interface Agent { - uuid: string; - id: string; - name: string; - description: string; - status: string; + uuid: string; + id: string; + name: string; + description: string; + status: string; } const agents = ref([]); @@ -16,83 +16,83 @@ const loading = ref(false); const loadError = ref(false); const fetchAgents = async () => { - loading.value = true; - loadError.value = false; - try { - const response = await apiClient.get('/api/agent/'); - agents.value = Array.isArray(response.data) ? response.data : []; - } catch (error) { - console.error('Failed to fetch agents:', error); - message.error('Failed to load agents'); - agents.value = []; - loadError.value = true; - } finally { - loading.value = false; - } + loading.value = true; + loadError.value = false; + try { + const response = await apiClient.get('/api/agent/'); + agents.value = Array.isArray(response.data) ? response.data : []; + } catch (error) { + console.error('Failed to fetch agents:', error); + message.error('Failed to load agents'); + agents.value = []; + loadError.value = true; + } finally { + loading.value = false; + } }; onMounted(() => { - fetchAgents(); + fetchAgents(); }); diff --git a/src/views/InviteAccept.vue b/src/views/InviteAccept.vue new file mode 100644 index 0000000..41e3e3d --- /dev/null +++ b/src/views/InviteAccept.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/views/OnboardingFlow.vue b/src/views/OnboardingFlow.vue index 17ac7d1..fb73496 100644 --- a/src/views/OnboardingFlow.vue +++ b/src/views/OnboardingFlow.vue @@ -3,85 +3,85 @@ import { ref } from 'vue'; import { Card, Typography, Timeline, Button, Space } from 'ant-design-vue'; const steps = ref([ - { - id: 1, - title: 'Welcome & Orientation', - description: 'Intro to company, mission and tools.', - eta: 15, - }, - { - id: 2, - title: 'Account Setup', - description: 'Set up accounts, access, and credentials.', - eta: 20, - }, - { - id: 3, - title: 'Team Introductions', - description: 'Meet key stakeholders and team rituals.', - eta: 30, - }, + { + id: 1, + title: 'Welcome & Orientation', + description: 'Intro to company, mission and tools.', + eta: 15, + }, + { + id: 2, + title: 'Account Setup', + description: 'Set up accounts, access, and credentials.', + eta: 20, + }, + { + id: 3, + title: 'Team Introductions', + description: 'Meet key stakeholders and team rituals.', + eta: 30, + }, ]); diff --git a/src/views/OrganizationManage.vue b/src/views/OrganizationManage.vue new file mode 100644 index 0000000..f871131 --- /dev/null +++ b/src/views/OrganizationManage.vue @@ -0,0 +1,485 @@ + + + + + diff --git a/src/views/OrganizationView.vue b/src/views/OrganizationView.vue new file mode 100644 index 0000000..fabb610 --- /dev/null +++ b/src/views/OrganizationView.vue @@ -0,0 +1,226 @@ + + + + +