Tentative changes

This commit is contained in:
Viswamedha Nalabotu 2025-12-18 23:27:24 +00:00
parent b9252068c4
commit efc794381f
17 changed files with 2083 additions and 652 deletions

View file

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

View file

@ -1,28 +1,54 @@
from django.contrib import admin 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) @admin.register(Domain)
class DomainAdmin(admin.ModelAdmin): class DomainAdmin(admin.ModelAdmin):
list_display = ('name', 'uuid') list_display = ('name', 'organization', 'uuid')
search_fields = ('name',) list_filter = ('organization',)
search_fields = ('name', 'organization__name')
readonly_fields = ('uuid',) readonly_fields = ('uuid',)
fieldsets = ( fieldsets = (
(None, {'fields': ('name', 'uuid')}), (None, {'fields': ('name', 'uuid')}),
('Description', {'fields': ('description',)}), ('Description', {'fields': ('description',)}),
('Organization', {'fields': ('organization',)}),
) )
@admin.register(Organisation) @admin.register(DomainMembership)
class OrganisationAdmin(admin.ModelAdmin): class DomainMembershipAdmin(admin.ModelAdmin):
list_display = ('name', 'uuid', 'created_at', 'updated_at') list_display = ('user', 'domain', 'created_at')
search_fields = ('name',) list_filter = ('created_at',)
readonly_fields = ('uuid', 'created_at', 'updated_at') search_fields = ('user__email_address', 'domain__name')
fieldsets = ( readonly_fields = ('created_at', 'updated_at')
(None, {'fields': ('name', 'uuid')}),
('Relations', {'fields': ('managers', 'employees', 'domains')}),
('Dates', {'fields': ('created_at', 'updated_at')}),
)
@admin.register(Dataset) @admin.register(Dataset)
@ -36,3 +62,4 @@ class DatasetAdmin(admin.ModelAdmin):
('File', {'fields': ('datafile',)}), ('File', {'fields': ('datafile',)}),
('Dates', {'fields': ('created_at', 'updated_at')}), ('Dates', {'fields': ('created_at', 'updated_at')}),
) )

View file

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

View file

@ -6,31 +6,108 @@ from django.db.models import (
UUIDField, UUIDField,
Model, Model,
TextField, TextField,
ManyToManyField,
DateTimeField,
BooleanField,
TextChoices,
) )
from django.utils.translation import gettext_lazy as _
from uuid import uuid4 from uuid import uuid4
from datetime import timedelta
from django.utils import timezone
from apps.users.models import TimeStampMixin, User 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): class Domain(Model):
name = CharField(max_length = 255, unique = True) name = CharField(max_length=255, unique=True)
uuid = UUIDField(default = uuid4, editable = False, unique = True) uuid = UUIDField(default=uuid4, editable=False, unique=True)
description = TextField(blank = True, default = "") 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: def __str__(self) -> str:
return self.name return self.name
class Organisation(TimeStampMixin, Model):
name = CharField(max_length = 255, unique = True) class DomainMembership(TimeStampMixin, Model):
uuid = UUIDField(default = uuid4, editable = False, unique = True)
managers = ForeignKey(User, on_delete = CASCADE, related_name = "managed_organisations") user = ForeignKey(User, on_delete=CASCADE, related_name="domain_memberships")
employees = ForeignKey(User, on_delete = CASCADE, related_name = "organisations") domain = ForeignKey(Domain, on_delete=CASCADE, related_name="memberships")
domains = ForeignKey(Domain, on_delete = CASCADE, related_name = "organisations")
class Meta:
verbose_name = _("Domain Membership")
verbose_name_plural = _("Domain Memberships")
unique_together = [["user", "domain"]]
def __str__(self) -> str: def __str__(self) -> str:
return self.name return f"{self.user.full_name} - {self.domain.name}"
class Dataset(TimeStampMixin, Model): class Dataset(TimeStampMixin, Model):

View file

@ -1,19 +1,79 @@
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer 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): 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: class Meta:
model = Domain model = Domain
fields = ['id', 'name', 'description', 'uuid'] fields = ['id', 'uuid', 'name', 'description', 'organization', 'organization_id', 'member_count']
read_only_fields = ['uuid']
def get_member_count(self, obj):
class OrganisationSerializer(ModelSerializer): return obj.memberships.count()
class Meta:
model = Organisation
fields = ['id', 'name', 'managers', 'employees', 'domains', 'uuid', 'created_at', 'updated_at']
class DatasetSerializer(ModelSerializer): class DatasetSerializer(ModelSerializer):

View file

@ -1,28 +1,246 @@
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticatedOrReadOnly from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from apps.domains.models import Domain, Organisation, Dataset from rest_framework.decorators import action
from apps.domains.serializers import DomainSerializer, OrganisationSerializer, DatasetSerializer from rest_framework.response import Response
from rest_framework import status
from django.shortcuts import get_object_or_404
from django.utils import timezone
from apps.domains.models import Domain, Organization, Dataset, OrganizationMembership, InviteToken, DomainMembership
from apps.domains.serializers import (
DomainSerializer,
OrganizationSerializer,
DatasetSerializer,
OrganizationMembershipSerializer,
InviteTokenSerializer,
DomainMembershipSerializer,
)
class OrganizationViewSet(ModelViewSet):
queryset = Organization.objects.all()
serializer_class = OrganizationSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'uuid'
def get_queryset(self):
user = self.request.user
return Organization.objects.filter(memberships__user=user).distinct()
def perform_create(self, serializer):
org = serializer.save(owner=self.request.user)
OrganizationMembership.objects.create(
organization=org,
user=self.request.user,
role=OrganizationMembership.Role.EMPLOYER
)
def update(self, request, *args, **kwargs):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can update organization details"},
status=status.HTTP_403_FORBIDDEN
)
return super().update(request, *args, **kwargs)
@action(detail=True, methods=['get'])
def members(self, request, uuid=None):
org = self.get_object()
memberships = org.memberships.all()
serializer = OrganizationMembershipSerializer(memberships, many=True)
return Response(serializer.data)
@action(detail=True, methods=['patch'], url_path='members/(?P<user_id>[^/.]+)')
def update_member(self, request, uuid=None, user_id=None):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can update member roles"},
status=status.HTTP_403_FORBIDDEN
)
target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id)
serializer = OrganizationMembershipSerializer(target_membership, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['delete'], url_path='members/(?P<user_id>[^/.]+)')
def remove_member(self, request, uuid=None, user_id=None):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can remove members"},
status=status.HTTP_403_FORBIDDEN
)
target_membership = get_object_or_404(OrganizationMembership, organization=org, user_id=user_id)
if target_membership.user == org.owner:
return Response(
{"error": "Cannot remove the organization owner"},
status=status.HTTP_400_BAD_REQUEST
)
target_membership.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['get', 'post'])
def invites(self, request, uuid=None):
org = self.get_object()
if request.method == 'GET':
tokens = org.invite_tokens.filter(is_active=True, used_by__isnull=True)
serializer = InviteTokenSerializer(tokens, many=True, context={'request': request})
return Response(serializer.data)
elif request.method == 'POST':
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can create invites"},
status=status.HTTP_403_FORBIDDEN
)
token = InviteToken.objects.create(
organization=org,
created_by=request.user
)
serializer = InviteTokenSerializer(token, context={'request': request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['delete'], url_path='invites/(?P<token>[^/.]+)')
def revoke_invite(self, request, uuid=None, token=None):
org = self.get_object()
membership = OrganizationMembership.objects.filter(
organization=org,
user=request.user,
role=OrganizationMembership.Role.EMPLOYER
).first()
if not membership:
return Response(
{"error": "Only employers can revoke invites"},
status=status.HTTP_403_FORBIDDEN
)
invite = get_object_or_404(InviteToken, organization=org, token=token)
invite.is_active = False
invite.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=['get'])
def domains(self, request, uuid=None):
org = self.get_object()
domains = org.domains.all()
serializer = DomainSerializer(domains, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'], url_path='domains/(?P<domain_id>[^/.]+)/members')
def domain_members(self, request, uuid=None, domain_id=None):
org = self.get_object()
domain = get_object_or_404(Domain, organization=org, id=domain_id)
memberships = domain.memberships.all()
serializer = DomainMembershipSerializer(memberships, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='domains/(?P<domain_id>[^/.]+)/members')
def add_domain_member(self, request, uuid=None, domain_id=None):
org = self.get_object()
domain = get_object_or_404(Domain, organization=org, id=domain_id)
user_id = request.data.get('user_id')
org_membership = OrganizationMembership.objects.filter(
organization=org,
user_id=user_id
).first()
if not org_membership:
return Response(
{"error": "User must be a member of the organization first"},
status=status.HTTP_400_BAD_REQUEST
)
domain_membership, created = DomainMembership.objects.get_or_create(
domain=domain,
user_id=user_id
)
serializer = DomainMembershipSerializer(domain_membership)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
class InviteViewSet(ModelViewSet):
queryset = InviteToken.objects.all()
serializer_class = InviteTokenSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'token'
http_method_names = ['get', 'post']
def get_queryset(self):
return InviteToken.objects.filter(is_active=True, used_by__isnull=True)
@action(detail=True, methods=['post'])
def accept(self, request, token=None):
invite = self.get_object()
if not invite.is_valid():
return Response(
{"error": "This invite is no longer valid"},
status=status.HTTP_400_BAD_REQUEST
)
membership, created = OrganizationMembership.objects.get_or_create(
organization=invite.organization,
user=request.user,
defaults={'role': OrganizationMembership.Role.EMPLOYEE}
)
if created:
invite.used_by = request.user
invite.used_at = timezone.now()
invite.is_active = False
invite.save()
serializer = OrganizationSerializer(invite.organization)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
class DomainViewSet(ModelViewSet): class DomainViewSet(ModelViewSet):
queryset = Domain.objects.all() queryset = Domain.objects.all()
serializer_class = DomainSerializer serializer_class = DomainSerializer
permission_classes = [IsAuthenticatedOrReadOnly] permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid' lookup_field = 'uuid'
def get_queryset(self):
class OrganisationViewSet(ModelViewSet): user = self.request.user
if user.is_authenticated:
queryset = Organisation.objects.all() return Domain.objects.filter(
serializer_class = OrganisationSerializer organization__memberships__user=user
permission_classes = [IsAuthenticatedOrReadOnly] ).distinct()
lookup_field = 'uuid' return Domain.objects.none()
class DatasetViewSet(ModelViewSet): class DatasetViewSet(ModelViewSet):
queryset = Dataset.objects.all() queryset = Dataset.objects.all()
serializer_class = DatasetSerializer serializer_class = DatasetSerializer
permission_classes = [IsAuthenticatedOrReadOnly] permission_classes = [IsAuthenticatedOrReadOnly]
lookup_field = 'uuid' lookup_field = 'uuid'

View file

@ -1,15 +1,16 @@
from rest_framework.routers import DefaultRouter 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.users.viewsets import UserViewSet
from apps.agents.viewsets import AgentViewSet, AgentExecutionViewSet from apps.agents.viewsets import AgentViewSet, AgentExecutionViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'domain', DomainViewSet, basename = 'domain') router.register(r'organization', OrganizationViewSet, basename='organization')
router.register(r'organisation', OrganisationViewSet, basename = 'organisation') router.register(r'invite', InviteViewSet, basename='invite')
router.register(r'dataset', DatasetViewSet, basename = 'dataset') router.register(r'domain', DomainViewSet, basename='domain')
router.register(r'user', UserViewSet, basename = 'user') router.register(r'dataset', DatasetViewSet, basename='dataset')
router.register(r'agent', AgentViewSet, basename = 'agent') router.register(r'user', UserViewSet, basename='user')
router.register(r'agent-execution', AgentExecutionViewSet, basename = 'agent-execution') router.register(r'agent', AgentViewSet, basename='agent')
router.register(r'agent-execution', AgentExecutionViewSet, basename='agent-execution')
urlpatterns = router.urls urlpatterns = router.urls

View file

@ -14,6 +14,7 @@ import {
DashboardOutlined, DashboardOutlined,
LoginOutlined, LoginOutlined,
UserAddOutlined, UserAddOutlined,
BuildOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
@ -79,6 +80,12 @@ const navItems = [
icon: DashboardOutlined, icon: DashboardOutlined,
path: '/progress', path: '/progress',
}, },
{
key: '/organizations',
label: 'Organizations',
icon: BuildOutlined,
path: '/organizations',
},
]; ];
const visibleNavItems = computed(() => const visibleNavItems = computed(() =>
@ -88,15 +95,20 @@ const visibleNavItems = computed(() =>
); );
const selectedKeys = computed(() => { const selectedKeys = computed(() => {
const match = visibleNavItems.value.find((item) => const match = visibleNavItems.value.find((item) => {
route.path.startsWith(item.key) if (item.key === '/') return route.path === '/';
); return route.path.startsWith(item.key);
});
return match ? [match.key] : []; return match ? [match.key] : [];
}); });
const onSelect: MenuProps['onSelect'] = ({ key }) => { const onSelect: MenuProps['onSelect'] = ({ key }) => {
const item = visibleNavItems.value.find((n) => n.key === key); const item = visibleNavItems.value.find((n) => n.key === key);
if (item) router.push(item.path); if (item) {
if (route.path !== item.path) {
router.push(item.path);
}
}
}; };
const handleLogout = async () => { const handleLogout = async () => {
@ -112,7 +124,9 @@ onMounted(() => {
<template> <template>
<Layout class="shell"> <Layout class="shell">
<Layout.Header class="shell-header"> <Layout.Header class="shell-header">
<div class="brand" @click="router.push('/')">Dynavera</div> <div class="brand" @click="route.path !== '/' && router.push('/')">
Dynavera
</div>
<Menu <Menu
mode="horizontal" mode="horizontal"
theme="dark" theme="dark"

View file

@ -75,6 +75,24 @@ const router = createRouter({
component: () => import('../views/Resources.vue'), component: () => import('../views/Resources.vue'),
meta: { requiresAuth: true }, 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 },
},
], ],
}); });

179
src/views/InviteAccept.vue Normal file
View file

@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
Card,
Typography,
Button,
Spin,
message,
Result,
} from 'ant-design-vue';
import { apiClient, isAxiosError } from '../lib/api';
const route = useRoute();
const router = useRouter();
interface InviteToken {
id: number;
token: string;
organization: {
id: number;
uuid: string;
name: string;
description: string;
};
expires_at: string;
is_valid: boolean;
}
const token = route.params.token as string;
const invite = ref<InviteToken | null>(null);
const loading = ref(false);
const accepting = ref(false);
const accepted = ref(false);
const error = ref<string | null>(null);
const fetchInvite = async () => {
loading.value = true;
error.value = null;
try {
const response = await apiClient.get<InviteToken>(
`/api/invite/${token}/`
);
invite.value = response.data;
if (!response.data.is_valid) {
error.value = 'This invite is no longer valid or has expired.';
}
} catch (err) {
console.error('Failed to fetch invite:', err);
if (isAxiosError(err) && err.response?.status === 404) {
error.value = 'Invalid invite link.';
} else {
error.value = 'Failed to load invite details.';
}
} finally {
loading.value = false;
}
};
const acceptInvite = async () => {
accepting.value = true;
try {
await apiClient.post(`/api/invite/${token}/accept/`);
message.success('Successfully joined organization');
accepted.value = true;
setTimeout(() => {
router.push(`/organizations/${invite.value?.organization.uuid}`);
}, 2000);
} catch (err) {
console.error('Failed to accept invite:', err);
if (isAxiosError(err)) {
message.error(
err.response?.data?.error || 'Failed to accept invite'
);
}
} finally {
accepting.value = false;
}
};
onMounted(() => {
fetchInvite();
});
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading invite...">
<Card class="panel" :bordered="false">
<div v-if="error">
<Result status="error" :title="error">
<template #extra>
<Button type="primary" @click="router.push('/')">
Go Home
</Button>
</template>
</Result>
</div>
<div v-else-if="accepted">
<Result
status="success"
title="Successfully Joined Organization"
sub-title="Redirecting to organization page..."
/>
</div>
<div v-else-if="invite" class="invite-content">
<Typography.Title :level="2">
Organization Invite
</Typography.Title>
<div class="org-info">
<Typography.Title :level="4">
{{ invite.organization.name }}
</Typography.Title>
<Typography.Paragraph>
{{
invite.organization.description ||
'No description provided'
}}
</Typography.Paragraph>
<Typography.Paragraph type="secondary">
Expires:
{{ new Date(invite.expires_at).toLocaleString() }}
</Typography.Paragraph>
</div>
<div class="actions">
<Typography.Paragraph>
You've been invited to join this organization. Click
accept to become a member.
</Typography.Paragraph>
<Button
type="primary"
size="large"
:loading="accepting"
@click="acceptInvite"
>
Accept Invite
</Button>
</div>
</div>
</Card>
</Spin>
</div>
</template>
<style scoped>
.page {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.invite-content {
text-align: center;
padding: 2rem;
}
.org-info {
background: #1f2937;
border-radius: 8px;
padding: 1.5rem;
margin: 2rem 0;
}
.actions {
margin-top: 2rem;
}
</style>

View file

@ -0,0 +1,485 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import {
Card,
Typography,
Button,
List,
Space,
Spin,
Input,
message,
Tag,
Divider,
Modal,
Select,
Tabs,
} from 'ant-design-vue';
import { apiClient, isAxiosError } from '../lib/api';
const route = useRoute();
interface Organization {
id: number;
uuid: string;
name: string;
description: string;
owner: {
id: number;
full_name: string;
};
member_count: number;
domain_count: number;
}
interface Member {
id: number;
user: {
id: number;
full_name: string;
email_address: string;
};
role: string;
created_at: string;
}
interface InviteToken {
id: number;
token: string;
invite_url: string;
created_by: {
full_name: string;
};
expires_at: string;
is_valid: boolean;
}
interface Domain {
id: number;
uuid: string;
name: string;
description: string;
member_count: number;
}
const orgId = route.params.id as string;
const organization = ref<Organization | null>(null);
const members = ref<Member[]>([]);
const invites = ref<InviteToken[]>([]);
const domains = ref<Domain[]>([]);
const loading = ref(false);
const inviteModalVisible = ref(false);
const newInviteUrl = ref('');
const editingDescription = ref(false);
const newDescription = ref('');
const fetchOrganization = async () => {
loading.value = true;
try {
const response = await apiClient.get<Organization>(
`/api/organization/${orgId}/`
);
organization.value = response.data;
newDescription.value = response.data.description;
} catch (error) {
console.error('Failed to fetch organization:', error);
message.error('Failed to load organization details');
} finally {
loading.value = false;
}
};
const fetchMembers = async () => {
try {
const response = await apiClient.get<Member[]>(
`/api/organization/${orgId}/members/`
);
members.value = response.data;
} catch (error) {
console.error('Failed to fetch members:', error);
}
};
const fetchInvites = async () => {
try {
const response = await apiClient.get<InviteToken[]>(
`/api/organization/${orgId}/invites/`
);
invites.value = response.data;
} catch (error) {
console.error('Failed to fetch invites:', error);
}
};
const fetchDomains = async () => {
try {
const response = await apiClient.get<Domain[]>(
`/api/organization/${orgId}/domains/`
);
domains.value = response.data;
} catch (error) {
console.error('Failed to fetch domains:', error);
}
};
const createInvite = async () => {
try {
const response = await apiClient.post<InviteToken>(
`/api/organization/${orgId}/invites/`
);
newInviteUrl.value = response.data.invite_url;
inviteModalVisible.value = true;
fetchInvites();
} catch (error) {
console.error('Failed to create invite:', error);
message.error('Failed to create invite');
}
};
const copyInviteUrl = () => {
window.navigator.clipboard.writeText(newInviteUrl.value);
message.success('Invite URL copied to clipboard');
};
const copyUrl = (url: string) => {
window.navigator.clipboard.writeText(url);
message.success('Copied to clipboard');
};
const revokeInvite = async (token: string) => {
try {
await apiClient.delete(`/api/organization/${orgId}/invites/${token}/`);
message.success('Invite revoked');
fetchInvites();
} catch (error) {
console.error('Failed to revoke invite:', error);
message.error('Failed to revoke invite');
}
};
const updateMemberRole = async (userId: number, newRole: string) => {
try {
await apiClient.patch(`/api/organization/${orgId}/members/${userId}/`, {
role: newRole,
});
message.success('Member role updated');
fetchMembers();
} catch (error) {
console.error('Failed to update member role:', error);
if (isAxiosError(error)) {
message.error(
error.response?.data?.error || 'Failed to update member role'
);
}
}
};
const removeMember = async (userId: number) => {
try {
await apiClient.delete(`/api/organization/${orgId}/members/${userId}/`);
message.success('Member removed');
fetchMembers();
} catch (error) {
console.error('Failed to remove member:', error);
if (isAxiosError(error)) {
message.error(
error.response?.data?.error || 'Failed to remove member'
);
}
}
};
const saveDescription = async () => {
try {
await apiClient.patch(`/api/organization/${orgId}/`, {
description: newDescription.value,
});
message.success('Description updated');
editingDescription.value = false;
fetchOrganization();
} catch (error) {
console.error('Failed to update description:', error);
message.error('Failed to update description');
}
};
onMounted(() => {
fetchOrganization();
fetchMembers();
fetchInvites();
fetchDomains();
});
</script>
<template>
<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>
<Tabs>
<Tabs.TabPane key="details" tab="Details">
<div class="section">
<Typography.Title :level="4">
Description
</Typography.Title>
<div v-if="!editingDescription">
<Typography.Paragraph>
{{
organization.description ||
'No description provided'
}}
</Typography.Paragraph>
<Button
@click="editingDescription = true"
size="small"
>
Edit Description
</Button>
</div>
<div v-else>
<Input.TextArea
v-model:value="newDescription"
:rows="4"
placeholder="Enter organization description"
/>
<Space style="margin-top: 0.5rem">
<Button
type="primary"
@click="saveDescription"
>
Save
</Button>
<Button @click="editingDescription = false">
Cancel
</Button>
</Space>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="members" tab="Members">
<div class="section">
<div class="section-header">
<Typography.Title :level="4">
Members ({{ members.length }})
</Typography.Title>
</div>
<List :data-source="members" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="member-item">
<List.Item.Meta
:title="item.user.full_name"
:description="
item.user.email_address
"
/>
<Space>
<Select
:value="item.role"
style="width: 120px"
@change="
(value) =>
updateMemberRole(
item.user.id,
value
)
"
>
<Select.Option value="employee">
Employee
</Select.Option>
<Select.Option value="employer">
Employer
</Select.Option>
</Select>
<Button
v-if="
item.user.id !==
organization.owner.id
"
danger
size="small"
@click="
removeMember(item.user.id)
"
>
Remove
</Button>
<Tag v-else color="blue">Owner</Tag>
</Space>
</List.Item>
</template>
</List>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="invites" tab="Invites">
<div class="section">
<div class="section-header">
<Typography.Title :level="4">
Invite Tokens
</Typography.Title>
<Button type="primary" @click="createInvite">
Create Invite
</Button>
</div>
<List
v-if="invites.length > 0"
:data-source="invites"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="invite-item">
<List.Item.Meta
:title="`Created by ${item.created_by.full_name}`"
:description="`Expires: ${new Date(
item.expires_at
).toLocaleDateString()}`"
/>
<Space>
<Tag
:color="
item.is_valid
? 'green'
: 'red'
"
>
{{
item.is_valid
? 'Valid'
: 'Expired'
}}
</Tag>
<Button
size="small"
@click="
copyUrl(item.invite_url)
"
>
Copy URL
</Button>
<Button
danger
size="small"
@click="
revokeInvite(item.token)
"
>
Revoke
</Button>
</Space>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
No active invites. Create one to invite new
members.
</Typography.Paragraph>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="domains" tab="Domains">
<div class="section">
<Typography.Title :level="4">
Domains ({{ domains.length }})
</Typography.Title>
<List
v-if="domains.length > 0"
:data-source="domains"
:bordered="false"
>
<template #renderItem="{ item }">
<List.Item class="domain-item">
<List.Item.Meta
:title="item.name"
:description="
item.description ||
'No description'
"
/>
<Tag
>{{
item.member_count
}}
members</Tag
>
</List.Item>
</template>
</List>
<Typography.Paragraph v-else type="secondary">
No domains in this organization yet.
</Typography.Paragraph>
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</Spin>
<Modal
v-model:open="inviteModalVisible"
title="Invite Created"
@ok="inviteModalVisible = false"
>
<div>
<Typography.Paragraph>
Share this URL with people you want to invite:
</Typography.Paragraph>
<Input
:value="newInviteUrl"
readonly
@click="copyInviteUrl"
style="cursor: pointer"
/>
<Button
type="primary"
block
style="margin-top: 1rem"
@click="copyInviteUrl"
>
Copy to Clipboard
</Button>
</div>
</Modal>
</div>
</template>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.section {
margin: 2rem 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.member-item :deep(.ant-list-item-meta-title),
.member-item :deep(.ant-list-item-meta-description),
.invite-item :deep(.ant-list-item-meta-title),
.invite-item :deep(.ant-list-item-meta-description),
.domain-item :deep(.ant-list-item-meta-title),
.domain-item :deep(.ant-list-item-meta-description) {
color: #e5e7eb;
}
</style>

View file

@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
Card,
Typography,
Button,
List,
Space,
Spin,
message,
Tag,
Divider,
} from 'ant-design-vue';
import { apiClient, isAxiosError } from '../lib/api';
const route = useRoute();
const router = useRouter();
interface Organization {
id: number;
uuid: string;
name: string;
description: string;
owner: {
id: number;
full_name: string;
email_address: string;
};
member_count: number;
domain_count: number;
created_at: string;
}
interface Domain {
id: number;
uuid: string;
name: string;
description: string;
member_count: number;
}
interface DomainMembership {
id: number;
domain: {
id: number;
name: string;
};
}
const orgId = route.params.id as string;
const organization = ref<Organization | null>(null);
const domains = ref<Domain[]>([]);
const userDomains = ref<number[]>([]);
const loading = ref(false);
const isOwner = computed(() => {
return false;
});
const fetchOrganization = async () => {
loading.value = true;
try {
const response = await apiClient.get<Organization>(
`/api/organization/${orgId}/`
);
organization.value = response.data;
} catch (error) {
console.error('Failed to fetch organization:', error);
message.error('Failed to load organization details');
} finally {
loading.value = false;
}
};
const fetchDomains = async () => {
try {
const response = await apiClient.get<Domain[]>(
`/api/organization/${orgId}/domains/`
);
domains.value = response.data;
} catch (error) {
console.error('Failed to fetch domains:', error);
}
};
const selectDomain = async (domainId: number) => {
try {
await apiClient.post(
`/api/organization/${orgId}/domains/${domainId}/members/`,
{ user_id: 'current' }
);
message.success('Successfully joined domain');
userDomains.value.push(domainId);
} catch (error) {
console.error('Failed to join domain:', error);
if (isAxiosError(error)) {
message.error(
error.response?.data?.error || 'Failed to join domain'
);
}
}
};
onMounted(() => {
fetchOrganization();
fetchDomains();
});
</script>
<template>
<div class="page">
<Spin :spinning="loading" tip="Loading organization...">
<Card v-if="organization" class="panel" :bordered="false">
<div class="header">
<Typography.Title :level="2">{{
organization.name
}}</Typography.Title>
<Button
v-if="isOwner"
type="primary"
@click="
router.push(
`/organizations/${organization.uuid}/manage`
)
"
>
Manage Organization
</Button>
</div>
<Typography.Paragraph v-if="organization.description">
{{ organization.description }}
</Typography.Paragraph>
<Typography.Paragraph v-else type="secondary">
No description provided
</Typography.Paragraph>
<Space direction="vertical" :size="4" style="margin: 1rem 0">
<div>
<Typography.Text strong>Owner:</Typography.Text>
{{ organization.owner.full_name }} ({{
organization.owner.email_address
}})
</div>
<div>
<Typography.Text strong>Members:</Typography.Text>
{{ organization.member_count }}
</div>
<div>
<Typography.Text strong>Domains:</Typography.Text>
{{ organization.domain_count }}
</div>
</Space>
<Divider />
<Typography.Title :level="4" class="section-title">
Available Domains
</Typography.Title>
<div v-if="domains.length > 0">
<List :data-source="domains" :bordered="false">
<template #renderItem="{ item }">
<List.Item class="domain-item">
<List.Item.Meta
:title="item.name"
:description="
item.description ||
'No description available'
"
/>
<Space>
<Tag>{{ item.member_count }} members</Tag>
<Button
v-if="!userDomains.includes(item.id)"
type="primary"
size="small"
@click="selectDomain(item.id)"
>
Join Domain
</Button>
<Tag v-else color="success">Joined</Tag>
</Space>
</List.Item>
</template>
</List>
</div>
<Typography.Paragraph v-else type="secondary">
No domains available in this organization.
</Typography.Paragraph>
</Card>
</Spin>
</div>
</template>
<style scoped>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.panel {
background: #0f172a;
border: 1px solid #1f2937;
color: #e5e7eb;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-title {
margin-top: 1.5rem !important;
margin-bottom: 1rem !important;
}
.domain-item :deep(.ant-list-item-meta-title),
.domain-item :deep(.ant-list-item-meta-description) {
color: #e5e7eb;
}
</style>