Browse Source

Merge pull request #8 from Rox0z/groups

Add group management
pull/2430/head
Leonardo Albudane 3 months ago
committed by GitHub
parent
commit
31b060fbe4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
32 changed files with 1654 additions and 275 deletions
  1. 19
      backend/config/urls.py
  2. 6
      backend/groups/admin.py
  3. 41
      backend/groups/models.py
  4. 72
      backend/groups/serializers.py
  5. 29
      backend/groups/urls.py
  6. 109
      backend/groups/views.py
  7. 68
      backend/users/serializers.py
  8. 12
      backend/users/urls.py
  9. 98
      backend/users/views.py
  10. 12
      frontend/components/auth/FormRegister.vue
  11. 216
      frontend/components/groups/FormCreate.vue
  12. 39
      frontend/components/groups/FormDelete.vue
  13. 229
      frontend/components/groups/FormUpdate.vue
  14. 127
      frontend/components/groups/GroupList.vue
  15. 8
      frontend/components/layout/TheHeader.vue
  16. 39
      frontend/domain/models/group/group.ts
  17. 8
      frontend/domain/models/user/user.ts
  18. 3
      frontend/i18n/en/generic.js
  19. 20
      frontend/i18n/en/group.js
  20. 1
      frontend/i18n/en/header.js
  21. 4
      frontend/i18n/en/index.js
  22. 141
      frontend/pages/groups/_id/index.vue
  23. 161
      frontend/pages/groups/index.vue
  24. 208
      frontend/pages/users/_id.vue
  25. 7
      frontend/plugins/repositories.ts
  26. 5
      frontend/plugins/services.ts
  27. 8
      frontend/repositories/auth/apiAuthRepository.ts
  28. 134
      frontend/repositories/group/apiGroupRepository.ts
  29. 16
      frontend/repositories/user/apiUserRepository.ts
  30. 82
      frontend/services/application/group/GroupApplicationService.ts
  31. 1
      frontend/services/application/user/UserApplicationService.ts
  32. 6
      frontend/store/auth.js

19
backend/config/urls.py

@ -24,6 +24,7 @@ from django.urls import include, path, re_path
from django.views.static import serve
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import permissions
schema_view = get_schema_view(
openapi.Info(
@ -33,6 +34,24 @@ schema_view = get_schema_view(
license=openapi.License(name="MIT License"),
),
public=True,
permission_classes=[permissions.AllowAny],
patterns=[
path("v1/", include("api.urls")),
path("v1/", include("roles.urls")),
path("v1/", include("users.urls")),
path("v1/", include("groups.urls")),
path("v1/", include("data_import.urls")),
path("v1/", include("data_export.urls")),
path("v1/", include("projects.urls")),
path("v1/", include("discussions.urls")),
path("v1/projects/<int:project_id>/metrics/", include("metrics.urls")),
path("v1/projects/<int:project_id>/", include("auto_labeling.urls")),
path("v1/projects/<int:project_id>/", include("examples.urls")),
path("v1/projects/<int:project_id>/", include("labels.urls")),
path("v1/projects/<int:project_id>/", include("label_types.urls")),
path("v1/social/", include("social.v1_urls")),
path("v1/health/", include("health_check.urls")),
],
)
urlpatterns = []

6
backend/groups/admin.py

@ -1,6 +0,0 @@
from django.contrib import admin
# Register your models here.
from .models import Group
admin.site.register(Group)

41
backend/groups/models.py

@ -1,41 +0,0 @@
from django.db import models
class Group(models.Model):
# Define fields exactly as they exist in auth_group
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=150, unique=True)
class Meta:
db_table = 'auth_group' # Explicitly map to existing table
managed = False # Prevent Django from modifying the table
class GroupPermissions(models.Model):
# Define fields exactly as they exist in auth_group_permissions
id = models.AutoField(primary_key=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
permission = models.ForeignKey('auth.Permission', on_delete=models.CASCADE)
class Meta:
db_table = 'auth_group_permissions' # Explicitly map to existing table
managed = False # Prevent Django from modifying the table
class Permission(models.Model):
# Define fields exactly as they exist in auth_permission
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)
content_type = models.ForeignKey('ContentType', on_delete=models.CASCADE)
codename = models.CharField(max_length=100)
class Meta:
db_table = 'auth_permission' # Explicitly map to existing table
managed = False # Prevent Django from modifying the table
class ContentType(models.Model):
# Define fields exactly as they exist in django_content_type
id = models.AutoField(primary_key=True)
app_label = models.CharField(max_length=100)
model = models.CharField(max_length=100)
class Meta:
db_table = 'django_content_type' # Explicitly map to existing table
managed = False # Prevent Django from modifying the table

72
backend/groups/serializers.py

@ -1,54 +1,42 @@
from rest_framework import serializers
from .models import Group, GroupPermissions, Permission
from django.contrib.auth.models import Group, Permission, ContentType
class GroupSerializer(serializers.ModelSerializer):
permission_names = serializers.SerializerMethodField()
permissions = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Permission.objects.all(),
required=False
)
class Meta:
model = Group
fields = ['id', 'name'] # Include only the fields you need
class GroupCreateSerializer(serializers.ModelSerializer):
class Meta:
model = Group
fields = ['name']
extra_kwargs = {
'name': {'required': True}
}
def create(self, validated_data):
group = Group(**validated_data)
group.save()
return group
def update(self, instance, validated_data):
instance.name = validated_data.get('name', instance.name)
instance.save()
return instance
def delete(self, instance):
instance.delete()
return instance
def validate(self, data):
if 'name' in data and not data['name']:
raise serializers.ValidationError("Group name cannot be empty.")
return data
class GroupPermissionsSerializer(serializers.ModelSerializer):
class Meta:
model = GroupPermissions
fields = ['id', 'group_id', 'permission_id'] # Include only the fields you need
fields = ['id', 'name', 'permissions', 'permission_names']
def get_permission_names(self, obj):
# Create a dictionary mapping permission IDs to their display names
permissions_dict = {}
for permission in Permission.objects.filter(group=obj):
permissions_dict[permission.id] = {
'name': permission.name,
'codename': permission.codename,
'content_type': permission.content_type.app_label + '.' + permission.content_type.model
}
return permissions_dict
class PermissionSerializer(serializers.ModelSerializer):
label = serializers.SerializerMethodField()
class Meta:
model = Permission
fields = ['id', 'name', 'content_type_id', 'codename'] # Include only the fields you need
extra_kwargs = {
'name': {'required': True},
'content_type_id': {'required': True},
'codename': {'required': True}
}
fields = '__all__'
def get_label(self, obj):
if obj.content_type:
return f"{obj.content_type.app_label} | {obj.content_type.model} | {obj.name}"
return obj.name
class ContentTypeSerializer(serializers.ModelSerializer):
class Meta:
model = 'ContentType'
fields = ['id', 'app_label', 'model'] # Include only the fields you need
extra_kwargs = {
'app_label': {'required': True},
'model': {'required': True}
}
model = ContentType
fields = '__all__'

29
backend/groups/urls.py

@ -1,23 +1,12 @@
from django.urls import path
from .views import (
Groups, GroupDetail, GroupCreate,
GroupPermissionsList, GroupPermissionsCreate, GroupPermissionsDetail,
PermissionList, PermissionCreate, PermissionDetail
)
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import GroupViewSet, PermissionViewSet, ContentTypeViewSet
router = DefaultRouter()
router.register(r'groups', GroupViewSet)
router.register(r'permissions', PermissionViewSet)
router.register(r'content-types', ContentTypeViewSet)
urlpatterns = [
# Group URLs
path(route="groups/<int:id>", view=GroupDetail.as_view(), name="group_detail"),
path(route="groups/create/", view=GroupCreate.as_view(), name="group_create"),
path(route="groups", view=Groups.as_view(), name="group_list"),
# GroupPermissions URLs
path(route="group-permissions/<int:id>", view=GroupPermissionsDetail.as_view(), name="group_permissions_detail"),
path(route="group-permissions/create/", view=GroupPermissionsCreate.as_view(), name="group_permissions_create"),
path(route="group-permissions", view=GroupPermissionsList.as_view(), name="group_permissions_list"),
# Permission URLs
path(route="permissions/<int:id>", view=PermissionDetail.as_view(), name="permission_detail"),
path(route="permissions/create/", view=PermissionCreate.as_view(), name="permission_create"),
path(route="permissions", view=PermissionList.as_view(), name="permission_list"),
path('', include(router.urls)),
]

109
backend/groups/views.py

@ -1,66 +1,51 @@
from django.shortcuts import render
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics
from rest_framework.permissions import IsAuthenticated
from .models import Group, GroupPermissions, Permission
from .serializers import GroupSerializer, GroupCreateSerializer, GroupPermissionsSerializer, PermissionSerializer
from projects.permissions import IsProjectAdmin
class Groups(generics.ListAPIView):
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
search_fields = ("name",)
ordering_fields = ("id", "name")
ordering = ("name",) # Default ordering
class GroupCreate(generics.CreateAPIView):
queryset = Group.objects.all()
serializer_class = GroupCreateSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
class GroupDetail(generics.RetrieveUpdateDestroyAPIView):
from rest_framework import viewsets, permissions, filters
from django.contrib.auth.models import Group, Permission, ContentType
from .serializers import GroupSerializer, PermissionSerializer, ContentTypeSerializer
class AdminWritePermission(permissions.BasePermission):
"""
Custom permission to allow only admins to create, update or delete objects.
All authenticated users can read.
"""
def has_permission(self, request, view):
# Allow read-only for authenticated users
if request.method in permissions.SAFE_METHODS:
return request.user and request.user.is_authenticated
# For write operations, require admin status
return request.user and request.user.is_staff
class GroupViewSet(viewsets.ModelViewSet):
"""
API endpoints for managing user groups
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
lookup_field = 'id'
class GroupPermissionsList(generics.ListAPIView):
queryset = GroupPermissions.objects.all()
serializer_class = GroupPermissionsSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
filterset_fields = ('group_id', 'permission_id')
ordering_fields = ('id',)
class GroupPermissionsCreate(generics.CreateAPIView):
queryset = GroupPermissions.objects.all()
serializer_class = GroupPermissionsSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
class GroupPermissionsDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = GroupPermissions.objects.all()
serializer_class = GroupPermissionsSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
lookup_field = 'id'
class PermissionList(generics.ListAPIView):
queryset = Permission.objects.all()
serializer_class = PermissionSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
search_fields = ('name', 'codename')
ordering_fields = ('id', 'name', 'codename')
ordering = ('name',)
class PermissionCreate(generics.CreateAPIView):
queryset = Permission.objects.all()
serializer_class = PermissionSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
class PermissionDetail(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [AdminWritePermission]
swagger_tags = ['Groups']
filter_backends = [filters.OrderingFilter]
ordering_fields = ['id', 'name']
ordering = ['name']
class PermissionViewSet(viewsets.ModelViewSet):
"""
API endpoints for viewing permissions
"""
queryset = Permission.objects.all()
serializer_class = PermissionSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
lookup_field = 'id'
permission_classes = [AdminWritePermission]
swagger_tags = ['Permissions']
filter_backends = [filters.OrderingFilter]
ordering_fields = ['id', 'name', 'codename', 'content_type']
ordering = ['content_type', 'codename']
class ContentTypeViewSet(viewsets.ModelViewSet):
"""
API endpoints for viewing content types
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer
permission_classes = [AdminWritePermission]
swagger_tags = ['Content Types']
filter_backends = [filters.OrderingFilter]
ordering_fields = ['id', 'app_label', 'model']
ordering = ['app_label', 'model']

68
backend/users/serializers.py

@ -1,15 +1,75 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from django.contrib.auth.models import Group
from django.contrib.auth.password_validation import validate_password
User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
groups_details = serializers.SerializerMethodField()
class Meta:
model = get_user_model()
fields = ("id", "username", "email", "is_staff", "is_superuser", "is_active")
model = User
fields = ("id", "username", "email", "is_staff", "is_superuser", "is_active", "groups", "groups_details")
def get_groups_details(self, obj):
groups_dict = {}
for group in obj.groups.all():
groups_dict[group.id] = {
'name': group.name
}
return groups_dict
class UserDetailSerializer(serializers.ModelSerializer):
groups = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Group.objects.all(),
required=False
)
groups_details = serializers.SerializerMethodField()
class Meta:
model = get_user_model()
model = User
fields = ("id", "username", "email", "is_staff", "is_superuser", "is_active",
"first_name", "last_name", "date_joined")
"first_name", "last_name", "date_joined", "groups", "groups_details")
def get_groups_details(self, obj):
groups_dict = {}
for group in obj.groups.all():
groups_dict[group.id] = {
'name': group.name
}
return groups_dict
class RegisterSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True)
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
password_confirm = serializers.CharField(write_only=True, required=True)
class Meta:
model = User
fields = ('username', 'email', 'password', 'password_confirm')
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError({"password_confirm": "Password fields didn't match."})
# Check if email already exists
if User.objects.filter(email=attrs['email']).exists():
raise serializers.ValidationError({"email": "User with this email already exists."})
return attrs
def create(self, validated_data):
# Remove password_confirm as we don't need it to create the user
validated_data.pop('password_confirm')
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data['email'],
password=validated_data['password']
)
return user

12
backend/users/urls.py

@ -1,11 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import UserViewSet, MeView, RegisterView
from .views import Me, UserCreation, Users, UserDetail
router = DefaultRouter()
router.register(r"users", UserViewSet)
urlpatterns = [
path(route="me", view=Me.as_view(), name="me"),
path(route="users/<int:user_id>", view=UserDetail.as_view(), name="user_detail"),
path(route="users", view=Users.as_view(), name="user_list"),
path(route="users/create/", view=UserCreation.as_view(), name="user_create"),
path("me", MeView.as_view(), name="me"),
path("register", RegisterView.as_view(), name="register"),
path("", include(router.urls)),
path("auth/", include("dj_rest_auth.urls")),
]

98
backend/users/views.py

@ -1,33 +1,59 @@
from dj_rest_auth.registration.serializers import RegisterSerializer
from django.contrib.auth.models import User
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics, status
from rest_framework.permissions import IsAdminUser, IsAuthenticated, AllowAny
from rest_framework import viewsets, permissions, filters, status
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.exceptions import PermissionDenied, ValidationError
from .serializers import UserSerializer, UserDetailSerializer
from projects.permissions import IsProjectAdmin
from rest_framework.views import APIView
from rest_framework.generics import CreateAPIView
from django_filters.rest_framework import DjangoFilterBackend
from .serializers import UserSerializer, UserDetailSerializer, RegisterSerializer
class AdminWritePermission(permissions.BasePermission):
"""
Custom permission to allow only admins to create, update or delete objects.
All authenticated users can read.
"""
def has_permission(self, request, view):
# Allow read-only for authenticated users
if request.method in permissions.SAFE_METHODS:
return request.user and request.user.is_authenticated
# For write operations, require admin status
return request.user and (request.user.is_staff or request.user.is_superuser)
class Me(APIView):
permission_classes = (IsAuthenticated,)
class MeView(APIView):
"""
API endpoint for the authenticated user to view their own details
"""
def get(self, request, *args, **kwargs):
permission_classes = [permissions.IsAuthenticated]
swagger_tags = ['Current User']
def get(self, request):
serializer = UserDetailSerializer(request.user, context={"request": request})
return Response(serializer.data)
class UserDetail(generics.RetrieveUpdateDestroyAPIView):
class UserViewSet(viewsets.ModelViewSet):
"""
API endpoints for managing users
"""
queryset = User.objects.all()
serializer_class = UserDetailSerializer
lookup_url_kwarg = "user_id"
permission_classes = [IsAuthenticated & (IsProjectAdmin | IsAdminUser)]
filter_backends = (DjangoFilterBackend,)
serializer_class = UserSerializer
permission_classes = [AdminWritePermission]
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
filterset_fields = ("username",)
search_fields = ("username", "email", "first_name", "last_name")
ordering_fields = ("id", "username", "email", "is_staff", "is_superuser", "date_joined")
ordering = ("username",)
swagger_tags = ['Users']
def get_queryset(self):
return self.queryset
def get_serializer_class(self):
if self.action in ['retrieve', 'create', 'update', 'partial_update']:
return UserDetailSerializer
return UserSerializer
def perform_update(self, serializer):
if serializer.validated_data.get("is_superuser") and not self.request.user.is_superuser:
@ -45,28 +71,30 @@ class UserDetail(generics.RetrieveUpdateDestroyAPIView):
raise ValidationError(f"User '{instance.username}' is a staff member and cannot be deleted.")
instance.delete()
class Users(generics.ListAPIView):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated & IsProjectAdmin]
filter_backends = (DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
search_fields = ("username",)
ordering_fields = ("username", "email", "is_staff", "is_superuser")
ordering = ("username",)
def perform_create(self, serializer):
user = serializer.save(self.request)
return user
class UserCreation(generics.CreateAPIView):
class RegisterView(CreateAPIView):
"""
API endpoint for admin to register new users
"""
serializer_class = RegisterSerializer
permission_classes = [IsAuthenticated & (IsProjectAdmin | IsAdminUser)]
permission_classes = [AdminWritePermission] # Changed from AllowAny to AdminWritePermission
swagger_tags = ['User Management']
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = self.perform_create(serializer)
user = serializer.save()
# Return user data without sensitive information
return_serializer = UserSerializer(user)
headers = self.get_success_headers(serializer.data)
return Response(UserSerializer(user).data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
user = serializer.save(self.request)
return user
return Response(
return_serializer.data,
status=status.HTTP_201_CREATED,
headers=headers
)

12
frontend/components/auth/FormRegister.vue

@ -36,11 +36,11 @@
type="password"
/>
<v-text-field
id="password2"
v-model="password2"
id="passwordConfirm"
v-model="passwordConfirm"
:rules="confirmPasswordRules"
:label="$t('user.confirmPassword')"
name="password2"
name="passwordConfirm"
:prepend-icon="mdiLock"
type="password"
/>
@ -72,7 +72,7 @@ export default Vue.extend({
username: '',
email: '',
password: '',
password2: '',
passwordConfirm: '',
userNameRules,
passwordRules,
emailRules,
@ -99,8 +99,8 @@ export default Vue.extend({
await this.register({
username: this.username,
email: this.email,
password1: this.password,
password2: this.password2
password: this.password,
passwordConfirm: this.passwordConfirm
})
this.$router.push(this.localePath('/users'))
} catch (error: any) {

216
frontend/components/groups/FormCreate.vue

@ -0,0 +1,216 @@
<template>
<v-card>
<v-card-title>{{ $t('group.create') }}</v-card-title>
<v-card-text>
<v-container>
<v-form ref="form" v-model="valid">
<v-text-field
v-model="name"
:label="$t('group.name')"
:rules="nameRules"
required
/>
<v-card-subtitle>{{ $t('group.permissions') }}</v-card-subtitle>
<v-progress-circular
v-if="isLoadingPermissions"
indeterminate
color="primary"
class="my-5"
></v-progress-circular>
<template v-else>
<v-autocomplete
v-model="selectedPermissions"
:items="permissions"
:item-text="permissionLabel"
:search-input.sync="permissionsSearch"
:label="$t('group.selectPermissions')"
:no-data-text="$t('vuetify.noDataAvailable')"
chips
small-chips
deletable-chips
multiple
clearable
dense
outlined
hide-selected
return-object
>
<template #selection="{ item, index }">
<v-chip
v-if="index === 0"
small
close
@click:close="removePermission(item)"
>
<span>{{ permissionLabel(item) }}</span>
</v-chip>
<span v-if="index === 1" class="grey--text text-caption">
(+{{ selectedPermissions.length - 1 }} {{ $t('generic.more') }})
</span>
</template>
<template #item="{ item }">
<v-list-item-content>
<v-list-item-title>
{{ permissionLabel(item) }}
</v-list-item-title>
<v-list-item-subtitle>
{{ item.codename }}
</v-list-item-subtitle>
</v-list-item-content>
</template>
</v-autocomplete>
<div v-if="selectedPermissions && selectedPermissions.length > 0" class="mt-4">
<div class="subtitle-1 mb-2">
{{ $t('group.selectedPermissions') }} ({{ selectedPermissions.length }})
</div>
<div class="selected-permissions-container">
<v-chip-group column>
<v-chip
v-for="permission in selectedPermissions"
:key="permission.id"
small
close
class="ma-1"
@click:close="removePermission(permission)"
>
{{ permissionLabel(permission) }}
</v-chip>
</v-chip-group>
</div>
</div>
</template>
</v-form>
</v-container>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text color="grey" @click="$emit('cancel')">
{{ $t('generic.cancel') }}
</v-btn>
<v-btn
text
color="primary"
:disabled="!valid || isSubmitting"
@click="create"
>
{{ $t('generic.create') }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { Permission } from '@/domain/models/group/group'
export default Vue.extend({
data() {
return {
valid: false,
name: '',
nameRules: [
(v: string) => !!v || this.$t('rules.required')
],
isSubmitting: false,
permissions: [] as Permission[],
selectedPermissions: [] as Permission[],
isLoadingPermissions: false,
permissionsSearch: '',
filteredPermissions: [] as Permission[]
}
},
watch: {
permissionsSearch(_val) {
// When using v-autocomplete with server-side filtering,
// we could implement additional filtering here if needed
}
},
created() {
// Ensure selectedPermissions is initialized as an empty array
this.selectedPermissions = []
},
async mounted() {
await this.fetchPermissions()
},
methods: {
permissionLabel(item: Permission): string {
return item && (item.label || item.name) || ''
},
removePermission(permission: Permission) {
if (!this.selectedPermissions) {
this.selectedPermissions = []
return
}
this.selectedPermissions = this.selectedPermissions.filter(p => p.id !== permission.id)
},
async fetchPermissions() {
this.isLoadingPermissions = true
try {
// Fetch all permissions - could be paginated if there are too many
const response = await this.$services.group.listPermissions('limit=1000')
this.permissions = response.results
} catch (error) {
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to load permissions'
})
} finally {
this.isLoadingPermissions = false
}
},
async create() {
if (!(this.$refs.form as any).validate()) {
return
}
this.isSubmitting = true
try {
// Ensure we always have an array of permission IDs
const permissionIds = Array.isArray(this.selectedPermissions)
? this.selectedPermissions.map(p => p.id)
: [];
// Log for debugging
console.log('Sending permissions:', permissionIds)
await this.$services.group.createGroup({
name: this.name,
permissions: permissionIds
})
this.$emit('created')
} catch (error) {
console.error('Create group error:', error)
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to create group'
})
} finally {
this.isSubmitting = false
}
}
}
})
</script>
<style lang="scss" scoped>
.v-autocomplete {
margin-bottom: 10px;
}
.selected-permissions-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
background-color: #fafafa;
}
</style>

39
frontend/components/groups/FormDelete.vue

@ -0,0 +1,39 @@
<template>
<v-card>
<v-card-title class="headline">{{ $t('generic.deleteConfirm') }}</v-card-title>
<v-card-text>
{{ $tc('group.confirmDelete', selected.length, { count: selected.length }) }}
<v-list dense>
<v-list-item v-for="group in selected" :key="group.id">
<v-list-item-content>
<v-list-item-title>{{ group.name }}</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey darken-1" text @click="$emit('cancel')">
{{ $t('generic.cancel') }}
</v-btn>
<v-btn color="error" text @click="$emit('remove')">
{{ $t('generic.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import type { PropType } from 'vue'
import { Group } from '@/domain/models/group/group'
export default Vue.extend({
props: {
selected: {
type: Array as PropType<Group[]>,
required: true
}
}
})
</script>

229
frontend/components/groups/FormUpdate.vue

@ -0,0 +1,229 @@
<template>
<v-card>
<v-card-title>{{ $t('group.edit') }}</v-card-title>
<v-card-text>
<v-container>
<v-form ref="form" v-model="valid">
<v-text-field
v-model="name"
:label="$t('group.name')"
:rules="nameRules"
required
/>
<v-card-subtitle>{{ $t('group.permissions') }}</v-card-subtitle>
<v-progress-circular
v-if="isLoadingPermissions"
indeterminate
color="primary"
class="my-5"
></v-progress-circular>
<template v-else>
<v-autocomplete
v-model="selectedPermissions"
:items="permissions"
:item-text="permissionLabel"
:search-input.sync="permissionsSearch"
:label="$t('group.selectPermissions')"
:no-data-text="$t('vuetify.noDataAvailable')"
chips
small-chips
deletable-chips
multiple
clearable
dense
outlined
hide-selected
return-object
>
<template #selection="{ item, index }">
<v-chip
v-if="index === 0"
small
close
@click:close="removePermission(item)"
>
<span>{{ permissionLabel(item) }}</span>
</v-chip>
<span v-if="index === 1" class="grey--text text-caption">
(+{{ selectedPermissions.length - 1 }} {{ $t('generic.more') }})
</span>
</template>
<template #item="{ item }">
<v-list-item-content>
<v-list-item-title>
{{ permissionLabel(item) }}
</v-list-item-title>
<v-list-item-subtitle>
{{ item.codename }}
</v-list-item-subtitle>
</v-list-item-content>
</template>
</v-autocomplete>
<div v-if="selectedPermissions && selectedPermissions.length > 0" class="mt-4">
<div class="subtitle-1 mb-2">
{{ $t('group.selectedPermissions') }} ({{ selectedPermissions.length }})
</div>
<div class="selected-permissions-container">
<v-chip-group column>
<v-chip
v-for="permission in selectedPermissions"
:key="permission.id"
small
close
class="ma-1"
@click:close="removePermission(permission)"
>
{{ permissionLabel(permission) }}
</v-chip>
</v-chip-group>
</div>
</div>
</template>
</v-form>
</v-container>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text color="grey" @click="$emit('cancel')">
{{ $t('generic.cancel') }}
</v-btn>
<v-btn
text
color="primary"
:disabled="!valid || isSubmitting"
@click="update"
>
{{ $t('generic.save') }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { GroupDetails, Permission } from '@/domain/models/group/group'
export default Vue.extend({
props: {
group: {
type: Object as () => GroupDetails,
required: true
}
},
data() {
return {
valid: false,
name: this.group.name,
nameRules: [
(v: string) => !!v || this.$t('rules.required')
],
isSubmitting: false,
permissions: [] as Permission[],
selectedPermissions: [] as Permission[],
isLoadingPermissions: false,
permissionsSearch: ''
}
},
created() {
// Ensure selectedPermissions is initialized as an empty array
this.selectedPermissions = []
},
watch: {
permissionsSearch(_val) {
// When using v-autocomplete with server-side filtering,
// we could implement additional filtering here if needed
}
},
async mounted() {
await this.fetchPermissions()
},
methods: {
permissionLabel(item: Permission): string {
return item && (item.label || item.name) || ''
},
removePermission(permission: Permission) {
if (!this.selectedPermissions) {
this.selectedPermissions = []
return
}
this.selectedPermissions = this.selectedPermissions.filter(p => p.id !== permission.id)
},
async fetchPermissions() {
this.isLoadingPermissions = true
try {
// Fetch all permissions - could be paginated if there are too many
const response = await this.$services.group.listPermissions('limit=1000')
this.permissions = response.results
// Select permissions that belong to this group
if (this.group.permissions && this.group.permissions.length > 0) {
this.selectedPermissions = this.permissions.filter(p =>
this.group.permissions?.includes(p.id)
)
}
} catch (error) {
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to load permissions'
})
} finally {
this.isLoadingPermissions = false
}
},
async update() {
if (!(this.$refs.form as any).validate()) {
return
}
this.isSubmitting = true
try {
// Ensure we always have an array of permission IDs
const permissionIds = Array.isArray(this.selectedPermissions)
? this.selectedPermissions.map(p => p.id)
: [];
// Log for debugging
console.log('Updating with permissions:', permissionIds);
await this.$services.group.updateGroup(this.group.id, {
name: this.name,
permissions: permissionIds
})
this.$emit('updated')
} catch (error) {
console.error('Update group error:', error)
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to update group'
})
} finally {
this.isSubmitting = false
}
}
}
})
</script>
<style lang="scss" scoped>
.v-autocomplete {
margin-bottom: 10px;
}
.selected-permissions-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
background-color: #fafafa;
}
</style>

127
frontend/components/groups/GroupList.vue

@ -0,0 +1,127 @@
<template>
<v-data-table
:value="value"
:headers="headers"
:items="items"
:options.sync="options"
:server-items-length="total"
:search="search"
:loading="isLoading"
:loading-text="$t('generic.loading')"
:no-data-text="$t('vuetify.noDataAvailable')"
:footer-props="{
showFirstLastPage: true,
'items-per-page-options': [10, 50, 100],
'items-per-page-text': $t('vuetify.itemsPerPageText'),
'page-text': $t('dataset.pageText')
}"
item-key="id"
show-select
@input="$emit('input', $event)"
>
<template #top>
<v-text-field
v-model="search"
:prepend-inner-icon="mdiMagnify"
:label="$t('generic.search')"
single-line
hide-details
filled
/>
</template>
<template #[`item.name`]="{ item }">
<nuxt-link :to="localePath(`/groups/${item.id}`)">
<span>{{ item.name }}</span>
</nuxt-link>
</template>
</v-data-table>
</template>
<script lang="ts">
import { mdiMagnify } from '@mdi/js'
import type { PropType } from 'vue'
import Vue from 'vue'
import { DataOptions } from 'vuetify/types'
import { Group } from '~/domain/models/group/group'
export default Vue.extend({
props: {
isLoading: {
type: Boolean,
default: false,
required: true
},
items: {
type: Array as PropType<Group[]>,
default: () => [],
required: true
},
value: {
type: Array as PropType<Group[]>,
default: () => [],
required: true
},
total: {
type: Number,
default: 0,
required: true
}
},
data() {
return {
search: this.$route.query.q,
options: {} as DataOptions,
mdiMagnify
}
},
computed: {
headers(): { text: any; value: string; sortable?: boolean; align?: string }[] {
return [
{ text: this.$t('group.name'), value: 'name' },
{ text: this.$t('group.id'), value: 'id' }
]
}
},
watch: {
options: {
handler() {
this.updateQuery({
query: {
limit: this.options.itemsPerPage?.toString(),
offset: ((this.options.page ?? 1) - 1) * (this.options.itemsPerPage ?? 10),
q: this.search
}
})
},
deep: true
},
search() {
this.updateQuery({
query: {
limit: this.options.itemsPerPage?.toString(),
offset: '0',
q: this.search
}
})
this.options.page = 1
}
},
methods: {
updateQuery(payload: any) {
const { sortBy, sortDesc } = this.options
if (sortBy && sortDesc && sortBy.length === 1 && sortDesc.length === 1) {
payload.query.ordering = sortBy[0]
payload.query.orderBy = sortDesc[0] ? '-' : ''
} else {
payload.query.ordering = 'name'
payload.query.orderBy = ''
}
this.$emit('update:query', payload)
}
}
})
</script>

8
frontend/components/layout/TheHeader.vue

@ -37,6 +37,14 @@
>
{{ $t('header.users') }}
</v-btn>
<v-btn
v-if="isAuthenticated && isStaff"
text
class="text-capitalize"
@click="$router.push(localePath('/groups'))"
>
{{ $t('header.groups') }}
</v-btn>
<v-menu v-if="!isAuthenticated" open-on-hover offset-y>
<template #activator="{ on }">
<v-btn text v-on="on">

39
frontend/domain/models/group/group.ts

@ -0,0 +1,39 @@
export class Group {
constructor(
public id: number,
public name: string
) {}
}
export class GroupDetails extends Group {
constructor(
public id: number,
public name: string,
public permissions?: number[],
public permission_names?: {[key: string]: {
name: string;
codename: string;
content_type: string;
}}
) {
super(id, name);
}
}
export class Permission {
constructor(
public id: number,
public name: string,
public codename: string,
public content_type?: number,
public label?: string
) {}
}
export class ContentType {
constructor(
public id: number,
public app_label: string,
public model: string
) {}
}

8
frontend/domain/models/user/user.ts

@ -6,6 +6,8 @@ export class User {
readonly isActive: boolean,
readonly isSuperUser: boolean,
readonly isStaff: boolean,
readonly groups?: number[],
readonly groupsDetails?: { [key: string]: { name: string } }
) {}
}
@ -19,8 +21,10 @@ export class UserDetails extends User {
readonly isActive: boolean,
readonly isSuperUser: boolean,
readonly isStaff: boolean,
readonly dateJoined: string
readonly dateJoined: string,
readonly groups?: number[],
readonly groupsDetails?: { [key: string]: { name: string } }
) {
super(id, username, email, isActive, isSuperUser, isStaff)
super(id, username, email, isActive, isSuperUser, isStaff, groups, groupsDetails)
}
}

3
frontend/i18n/en/generic.js

@ -17,5 +17,6 @@ export default {
export: 'Export',
description: 'Description',
type: 'Type',
loading: 'Loading... Please wait'
loading: 'Loading... Please wait',
more: 'More',
}

20
frontend/i18n/en/group.js

@ -0,0 +1,20 @@
export default {
create: 'Create Group',
edit: 'Edit Group',
id: 'ID',
name: 'Name',
details: 'Group Details',
permissions: 'Permissions',
permissionName: 'Permission Name',
permissionCode: 'Permission Code',
noPermissions: 'No permissions assigned',
contentType: 'Content Type',
model: 'Model',
appLabel: 'App Label',
confirmDelete: 'Are you sure you want to delete {count} group? | Are you sure you want to delete {count} groups?',
selectPermissions: 'Search and select permissions',
selectedPermissions: 'Selected Permissions',
groups: 'Groups',
selectedGroups: 'Selected Groups',
selectGroups: 'Search and select groups',
}

1
frontend/i18n/en/header.js

@ -1,4 +1,5 @@
export default {
projects: 'projects',
users: 'users',
groups: 'groups'
}

4
frontend/i18n/en/index.js

@ -4,6 +4,7 @@ import generic from './generic'
import rules from './rules'
import toastui from './toastui'
import user from './user'
import group from './group'
import vuetify from './vuetify'
import annotation from './projects/annotation'
import dataset from './projects/dataset'
@ -35,5 +36,6 @@ export default {
comments,
overview,
statistics,
settings
settings,
group
}

141
frontend/pages/groups/_id/index.vue

@ -0,0 +1,141 @@
<template>
<v-card>
<v-card-title>
<v-btn
icon
class="mr-2"
@click="goBack"
>
<v-icon>{{ mdiArrowLeft }}</v-icon>
</v-btn>
{{ group.name }}
<v-spacer></v-spacer>
<v-btn
v-if="isStaff"
class="text-capitalize ms-2"
color="primary"
outlined
@click="dialogUpdate = true"
>
{{ $t('generic.edit') }}
</v-btn>
<v-dialog v-model="dialogUpdate" max-width="800px">
<form-update
:group="group"
@cancel="dialogUpdate = false"
@updated="handleUpdated"
/>
</v-dialog>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" sm="6">
<v-card outlined>
<v-card-title>{{ $t('group.details') }}</v-card-title>
<v-list-item>
<v-list-item-content>
<v-list-item-title>{{ $t('group.id') }}</v-list-item-title>
<v-list-item-subtitle>{{ group.id }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title>{{ $t('group.name') }}</v-list-item-title>
<v-list-item-subtitle>{{ group.name }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
</v-col>
<v-col cols="12" sm="6">
<v-card outlined>
<v-card-title>{{ $t('group.permissions') }}</v-card-title>
<v-card-text v-if="isLoading">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</v-card-text>
<v-card-text v-else>
<v-chip
v-for="(permission, id) in permissionsToDisplay"
:key="id"
color="primary"
small
class="ma-1"
>
{{ permission.name }}
</v-chip>
<div v-if="!permissionsToDisplay || Object.keys(permissionsToDisplay).length === 0" class="text-center grey--text">
{{ $t('group.noPermissions') }}
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { mapGetters } from 'vuex'
import { mdiArrowLeft } from '@mdi/js'
import { GroupDetails, Permission } from '@/domain/models/group/group'
import FormUpdate from '@/components/groups/FormUpdate.vue'
export default Vue.extend({
components: {
FormUpdate
},
layout: 'projects',
middleware: ['check-auth', 'auth'],
data() {
return {
group: {} as GroupDetails,
isLoading: false,
dialogUpdate: false,
permissions: [] as Permission[],
mdiArrowLeft
}
},
computed: {
...mapGetters('auth', ['isStaff']),
permissionsToDisplay() {
return this.group.permission_names || {}
}
},
async fetch() {
this.isLoading = true
try {
const id = Number(this.$route.params.id)
this.group = await this.$services.group.getGroup(id)
} catch (e) {
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to load group details'
})
} finally {
this.isLoading = false
}
},
methods: {
goBack() {
this.$router.push(this.localePath('/groups'))
},
handleUpdated() {
this.dialogUpdate = false
this.$fetch()
this.$store.dispatch('notification/setNotification', {
color: 'success',
text: 'Group updated successfully'
})
}
}
})
</script>

161
frontend/pages/groups/index.vue

@ -0,0 +1,161 @@
<template>
<v-card>
<v-card-title v-if="isStaff">
<v-btn
class="text-capitalize ms-2"
:disabled="!canDelete"
outlined
@click.stop="dialogDelete = true"
>
{{ $t('generic.delete') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
class="text-capitalize ms-2"
color="primary"
outlined
@click="dialogCreate = true"
>
{{ $t('generic.create') }}
</v-btn>
<v-dialog v-model="dialogDelete" max-width="500">
<form-delete :selected="selected" @cancel="dialogDelete = false" @remove="remove" />
</v-dialog>
<v-dialog v-model="dialogCreate" max-width="800">
<form-create @cancel="dialogCreate = false" @created="handleCreated" />
</v-dialog>
</v-card-title>
<group-list
v-model="selected"
:items="groups"
:is-loading="isLoading"
:total="total"
@update:query="updateQuery"
/>
</v-card>
</template>
<script lang="ts">
import _ from 'lodash'
import Vue from 'vue'
import { mapGetters } from 'vuex'
import GroupList from '@/components/groups/GroupList.vue'
import FormDelete from '@/components/groups/FormDelete.vue'
import FormCreate from '@/components/groups/FormCreate.vue'
import { Group } from '@/domain/models/group/group'
export default Vue.extend({
components: {
GroupList,
FormDelete,
FormCreate
},
layout: 'projects',
middleware: ['check-auth', 'auth'],
data() {
return {
dialogDelete: false,
dialogCreate: false,
groups: [] as Group[],
selected: [] as Group[],
isLoading: false,
total: 0
}
},
async fetch() {
this.isLoading = true
try {
const query = this.buildQuery()
const list = await this.$services.group.listGroups(query)
this.groups = list.results
this.total = list.count
} catch (e) {
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to load groups'
})
} finally {
this.isLoading = false
}
},
computed: {
...mapGetters('auth', ['isStaff']),
canDelete(): boolean {
return this.selected.length > 0
}
},
watch: {
'$route.query': _.debounce(function () {
// @ts-ignore
this.$fetch()
}, 500)
},
methods: {
buildQuery() {
const { q, limit, offset, ordering, orderBy } = this.$route.query
const params = new URLSearchParams()
if (q) params.append('search', q as string)
if (limit) params.append('limit', limit as string)
if (offset) params.append('offset', offset as string)
if (ordering && orderBy) {
const direction = orderBy === '-' ? '-' : ''
params.append('ordering', `${direction}${ordering}`)
}
return params.toString()
},
async remove() {
try {
for (const group of this.selected) {
await this.$services.group.deleteGroup(group.id)
}
this.$store.dispatch('notification/setNotification', {
color: 'success',
text: 'Groups deleted successfully'
})
this.$fetch()
} catch (e) {
this.$store.dispatch('notification/setNotification', {
color: 'error',
text: 'Failed to delete groups'
})
} finally {
this.dialogDelete = false
this.selected = []
}
},
updateQuery(query: { query: Record<string, string> }) {
this.$router.push({
query: {
...query.query
}
})
},
handleCreated() {
this.dialogCreate = false
this.$fetch()
this.$store.dispatch('notification/setNotification', {
color: 'success',
text: 'Group created successfully'
})
}
}
})
</script>
<style scoped>
::v-deep .v-dialog {
width: 800px;
}
</style>

208
frontend/pages/users/_id.vue

@ -29,6 +29,18 @@
<p>First name: {{ user.firstName }}</p>
<p>Last name: {{ user.lastName }}</p>
<p>Joined at: {{ new Date(user.dateJoined).toLocaleString() }}</p>
<p v-if="user.groups && user.groups.length > 0">
Groups:
<v-chip
v-for="groupId in user.groups"
:key="groupId"
class="ml-2 mr-1 my-1"
small
>
{{ getUserGroupName(groupId) }}
</v-chip>
</p>
<p v-else>Groups: <span class="grey--text">None</span></p>
</div>
<v-divider class="mb-5" />
@ -39,6 +51,62 @@
<v-text-field v-model="editedUser.username" label="Username" required />
<v-switch v-model="editedUser.isStaff" color="amber" label="Staff" />
<v-switch v-model="editedUser.isSuperUser" color="orange" label="Administrator" />
<v-card-subtitle>{{ $t('group.groups') || 'Groups' }}</v-card-subtitle>
<v-autocomplete
v-model="editedUser.selectedGroups"
:items="availableGroups"
:item-text="group => group.name"
:item-value="group => group.id"
:search-input.sync="groupsSearch"
:label="$t('group.selectGroups') || 'Select Groups'"
:no-data-text="$t('vuetify.noDataAvailable') || 'No data available'"
:loading="loadingGroups"
chips
small-chips
deletable-chips
multiple
clearable
dense
outlined
hide-selected
return-object
@change="updateSelectedGroupIds"
>
<template #selection="{ item, index }">
<v-chip
v-if="index === 0"
small
close
@click:close="removeGroup(item)"
>
<span>{{ item.name }}</span>
</v-chip>
<span v-if="index === 1" class="grey--text text-caption">
(+{{ editedUser.selectedGroups.length - 1 }} {{ $t('generic.more') || 'more' }})
</span>
</template>
</v-autocomplete>
<div v-if="editedUser.selectedGroups && editedUser.selectedGroups.length > 0" class="mt-4">
<div class="subtitle-1 mb-2">
{{ $t('group.selectedGroups') || 'Selected Groups' }} ({{ editedUser.selectedGroups.length }})
</div>
<div class="selected-groups-container">
<v-chip-group column>
<v-chip
v-for="group in editedUser.selectedGroups"
:key="group.id"
small
close
class="ma-1"
@click:close="removeGroup(group)"
>
{{ group.name }}
</v-chip>
</v-chip-group>
</div>
</div>
<v-card-actions>
<v-btn color="error" @click="handleSingleDelete" :disabled="loading">Delete User</v-btn>
@ -73,7 +141,6 @@
</v-card>
</v-dialog>
<!-- Modal de erro -->
<error-dialog :visible="errorDialog" :message="errorDialogMessage" @close="errorDialog = false" />
</v-container>
@ -84,8 +151,11 @@ import Vue from 'vue'
import { mdiArrowLeft } from '@mdi/js'
import ErrorDialog from '@/components/common/ErrorDialog.vue'
import { UserApplicationService } from '@/services/application/user/UserApplicationService'
import { GroupApplicationService } from '@/services/application/group/GroupApplicationService'
import { APIUserRepository } from '@/repositories/user/apiUserRepository'
import { APIGroupRepository } from '@/repositories/group/apiGroupRepository'
import { UserDetails } from '@/domain/models/user/user'
import { Group } from '@/domain/models/group/group'
export default Vue.extend({
components: {
@ -98,8 +168,13 @@ export default Vue.extend({
editedUser: {
username: '',
isStaff: false,
isSuperUser: false
isSuperUser: false,
groups: [] as number[],
selectedGroups: [] as Group[]
},
availableGroups: [] as Group[],
loadingGroups: false,
groupsSearch: '',
loading: false,
deleteLoading: false,
confirmDelete: false,
@ -115,6 +190,7 @@ export default Vue.extend({
async created() {
await this.fetchUser()
await this.fetchGroups()
},
methods: {
@ -123,16 +199,63 @@ export default Vue.extend({
const id = parseInt(this.$route.params.id)
const userService = new UserApplicationService(new APIUserRepository())
this.user = await userService.getUser(id)
// Save the user data to editedUser
this.editedUser = {
username: this.user.username,
isStaff: this.user.isStaff,
isSuperUser: this.user.isSuperUser
isSuperUser: this.user.isSuperUser,
groups: this.user.groups || [],
selectedGroups: [] // Will be populated after loading groups
}
} catch (error) {
} catch (error: any) {
this.errorMessage = error.message
}
},
async fetchGroups() {
try {
this.loadingGroups = true
const groupService = new GroupApplicationService(new APIGroupRepository())
const response = await groupService.listGroups()
this.availableGroups = response.results
// Set the selected groups objects based on user's group IDs
if (this.user && this.user.groups) {
this.editedUser.selectedGroups = this.availableGroups.filter(group =>
this.user!.groups!.includes(group.id)
)
}
} catch (error: any) {
this.errorMessage = `Failed to load groups: ${error.message}`
} finally {
this.loadingGroups = false
}
},
removeGroup(group: Group) {
this.editedUser.selectedGroups = this.editedUser.selectedGroups.filter(g => g.id !== group.id)
this.updateSelectedGroupIds()
},
updateSelectedGroupIds() {
this.editedUser.groups = this.editedUser.selectedGroups.map(group => group.id)
},
getUserGroupName(groupId: number): string {
// Try to find the group name from availableGroups first
const group = this.availableGroups.find(g => g.id === groupId)
if (group) return group.name
// If not found, try the user's groupsDetails
if (this.user?.groupsDetails && this.user.groupsDetails[groupId]) {
return this.user.groupsDetails[groupId].name
}
// Fallback
return `Group ${groupId}`
},
async updateUser() {
try {
this.loading = true
@ -144,12 +267,14 @@ export default Vue.extend({
await userService.updateUser(id, {
username: this.editedUser.username,
is_staff: this.editedUser.isStaff,
is_superuser: this.editedUser.isSuperUser
is_superuser: this.editedUser.isSuperUser,
groups: this.editedUser.groups // Using the array of IDs
})
this.successMessage = 'User profile updated successfully'
await this.fetchUser()
} catch (error) {
await this.fetchGroups() // Re-fetch groups to update the selection
} catch (error: any) {
this.errorMessage = error.message
} finally {
this.loading = false
@ -166,36 +291,36 @@ export default Vue.extend({
},
async deleteUser() {
try {
this.deleteLoading = true
this.errorMessage = ''
const id = parseInt(this.$route.params.id)
const userService = new UserApplicationService(new APIUserRepository())
await userService.deleteUser(id)
this.confirmDelete = false
this.$router.push('/users')
} catch (error) {
if (
error.message.includes('Failed to fetch') || // fetch padrão
error.message.includes('Network Error') || // Axios/network
error.message.includes('ECONNREFUSED') || // Node.js/axios backend offline
error.message.includes('502') || // Bad Gateway (nginx etc.)
error.message.includes('504') || // Gateway Timeout
error.message.includes('Failed to delete user') // custom errors
) {
this.errorDialogMessage =
'Unable to delete user: database connection failed. Check if the server is up.'
} else {
this.errorDialogMessage = error.message
}
this.errorDialog = true
this.confirmDelete = false
} finally {
this.deleteLoading = false
}
},
try {
this.deleteLoading = true
this.errorMessage = ''
const id = parseInt(this.$route.params.id)
const userService = new UserApplicationService(new APIUserRepository())
await userService.deleteUser(id)
this.confirmDelete = false
this.$router.push('/users')
} catch (error: any) {
if (
error.message.includes('Failed to fetch') || // fetch padrão
error.message.includes('Network Error') || // Axios/network
error.message.includes('ECONNREFUSED') || // Node.js/axios backend offline
error.message.includes('502') || // Bad Gateway (nginx etc.)
error.message.includes('504') || // Gateway Timeout
error.message.includes('Failed to delete user') // custom errors
) {
this.errorDialogMessage =
'Unable to delete user: database connection failed. Check if the server is up.'
} else {
this.errorDialogMessage = error.message
}
this.errorDialog = true
this.confirmDelete = false
} finally {
this.deleteLoading = false
}
},
goBack() {
this.$router.go(-1)
@ -203,3 +328,14 @@ export default Vue.extend({
}
})
</script>
<style lang="scss" scoped>
.selected-groups-container {
max-height: 200px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 8px;
background-color: #fafafa;
}
</style>

7
frontend/plugins/repositories.ts

@ -24,11 +24,15 @@ import { APICatalogRepository } from '@/repositories/upload/apiCatalogRepository
import { APIParseRepository } from '@/repositories/upload/apiParseRepository'
import { APIUserRepository } from '@/repositories/user/apiUserRepository'
import { APISegmentationRepository } from '~/repositories/tasks/apiSegmentationRepository'
import { APIGroupRepository } from '~/repositories/group/apiGroupRepository'
export interface Repositories {
// User
auth: APIAuthRepository
user: APIUserRepository
// Group
group: APIGroupRepository
// Project
project: APIProjectRepository
member: APIMemberRepository
@ -79,6 +83,9 @@ const repositories: Repositories = {
// User
auth: new APIAuthRepository(),
user: new APIUserRepository(),
// Group
group: new APIGroupRepository(),
// Project
project: new APIProjectRepository(),

5
frontend/plugins/services.ts

@ -9,6 +9,7 @@ import { BoundingBoxApplicationService } from '@/services/application/tasks/boun
import { SegmentationApplicationService } from '@/services/application/tasks/segmentation/segmentationApplicationService'
import { SequenceLabelingApplicationService } from '@/services/application/tasks/sequenceLabeling/sequenceLabelingApplicationService'
import { UserApplicationService } from '~/services/application/user/UserApplicationService'
import { GroupApplicationService } from '~/services/application/group/GroupApplicationService'
export interface Services {
categoryType: LabelApplicationService
@ -21,7 +22,8 @@ export interface Services {
tag: TagApplicationService
bbox: BoundingBoxApplicationService
segmentation: SegmentationApplicationService,
user: UserApplicationService
user: UserApplicationService,
group: GroupApplicationService
}
declare module 'vue/types/vue' {
@ -46,6 +48,7 @@ const plugin: Plugin = (_, inject) => {
bbox: new BoundingBoxApplicationService(repositories.boundingBox),
segmentation: new SegmentationApplicationService(repositories.segmentation),
user: new UserApplicationService(repositories.user),
group: new GroupApplicationService(repositories.group)
}
inject('services', services)
}

8
frontend/repositories/auth/apiAuthRepository.ts

@ -22,10 +22,10 @@ export class APIAuthRepository {
async register(
username: string,
email: string,
password1: string,
password2: string
password: string,
passwordConfirm: string
): Promise<void> {
const url = '/users/create/'
await this.request.post(url, { username, email, password1, password2 })
const url = '/register'
await this.request.post(url, { username, email, password, password_confirm: passwordConfirm })
}
}

134
frontend/repositories/group/apiGroupRepository.ts

@ -0,0 +1,134 @@
import ApiService from '@/services/api.service'
import { Group, GroupDetails, Permission, ContentType } from '@/domain/models/group/group'
import { PaginatedResponse } from '@/repositories/user/apiUserRepository'
function toGroupModel(item: { [key: string]: any }): Group {
return new Group(
item.id,
item.name
)
}
function toGroupDetailsModel(item: { [key: string]: any }): GroupDetails {
return new GroupDetails(
item.id,
item.name,
item.permissions,
item.permission_names
)
}
function toPermissionModel(item: { [key: string]: any }): Permission {
return new Permission(
item.id,
item.name,
item.codename,
item.content_type,
item.label
)
}
function toContentTypeModel(item: { [key: string]: any }): ContentType {
return new ContentType(
item.id,
item.app_label,
item.model
)
}
function toGroupModelList(response: PaginatedResponse<any>): PaginatedResponse<Group> {
return {
count: response.count,
next: response.next,
previous: response.previous,
results: response.results.map((item: any) => toGroupModel(item))
}
}
function toPermissionModelList(response: PaginatedResponse<any>): PaginatedResponse<Permission> {
return {
count: response.count,
next: response.next,
previous: response.previous,
results: response.results.map((item: any) => toPermissionModel(item))
}
}
function toContentTypeModelList(response: PaginatedResponse<any>): PaginatedResponse<ContentType> {
return {
count: response.count,
next: response.next,
previous: response.previous,
results: response.results.map((item: any) => toContentTypeModel(item))
}
}
export class APIGroupRepository {
constructor(private readonly request = ApiService) { }
// Group methods
async listGroups(query: string = ''): Promise<PaginatedResponse<Group>> {
const url = `/groups/?${query}`
const response = await this.request.get(url)
return toGroupModelList(response.data)
}
async getGroup(id: number): Promise<GroupDetails> {
const url = `/groups/${id}/`
const response = await this.request.get(url)
return toGroupDetailsModel(response.data)
}
async createGroup(data: { name: string, permissions?: number[] }): Promise<Group> {
const url = '/groups/'
// Ensure permissions is sent as an array even if empty
const payload = {
name: data.name,
permissions: data.permissions || []
}
const response = await this.request.post(url, payload)
return toGroupModel(response.data)
}
async updateGroup(id: number, data: { name?: string, permissions?: number[] }): Promise<Group> {
const url = `/groups/${id}/`
// Ensure permissions is sent as an array even if empty
const payload = { ...data }
if (!payload.permissions) {
payload.permissions = []
}
const response = await this.request.patch(url, payload)
return toGroupModel(response.data)
}
async deleteGroup(id: number): Promise<void> {
const url = `/groups/${id}/`
await this.request.delete(url)
}
// Permission methods
async listPermissions(query: string = ''): Promise<PaginatedResponse<Permission>> {
const url = `/permissions/?${query}`
const response = await this.request.get(url)
return toPermissionModelList(response.data)
}
async getPermission(id: number): Promise<Permission> {
const url = `/permissions/${id}/`
const response = await this.request.get(url)
return toPermissionModel(response.data)
}
// ContentType methods
async listContentTypes(query: string = ''): Promise<PaginatedResponse<ContentType>> {
const url = `/content-types/?${query}`
const response = await this.request.get(url)
return toContentTypeModelList(response.data)
}
async getContentType(id: number): Promise<ContentType> {
const url = `/content-types/${id}/`
const response = await this.request.get(url)
return toContentTypeModel(response.data)
}
}

16
frontend/repositories/user/apiUserRepository.ts

@ -15,7 +15,9 @@ function toModel(item: { [key: string]: any }): User {
item.email,
item.is_active,
item.is_superuser,
item.is_staff
item.is_staff,
item.groups,
item.groups_details
)
}
@ -30,7 +32,9 @@ function toModelDetails(item: { [key: string]: any }): UserDetails {
item.is_active,
item.is_superuser,
item.is_staff,
item.date_joined
item.date_joined,
item.groups,
item.groups_details
)
}
@ -53,25 +57,25 @@ export class APIUserRepository {
}
async list(query: string): Promise<PaginatedResponse<User>> {
const url = `/users?${query}`
const url = `/users/?${query}`
const response = await this.request.get(url)
return toModelList(response.data);
}
async getUser(id: number): Promise<UserDetails> {
const url = `/users/${id}`
const url = `/users/${id}/`
const response = await this.request.get(url)
return toModelDetails(response.data)
}
async updateUser(id: number, data: { [key: string]: any }): Promise<User> {
const url = `/users/${id}`
const url = `/users/${id}/`
const response = await this.request.patch(url, data)
return toModel(response.data)
}
async deleteUser(id: number): Promise<void> {
const url = `/users/${id}`
const url = `/users/${id}/`
await this.request.delete(url)
}
}

82
frontend/services/application/group/GroupApplicationService.ts

@ -0,0 +1,82 @@
import { Group, GroupDetails, Permission, ContentType } from '@/domain/models/group/group'
import { APIGroupRepository } from '@/repositories/group/apiGroupRepository'
import { PaginatedResponse } from '@/repositories/user/apiUserRepository'
export class GroupApplicationService {
constructor(private readonly repository: APIGroupRepository) { }
// Group methods
public async listGroups(query: string = ''): Promise<PaginatedResponse<Group>> {
try {
return await this.repository.listGroups(query)
} catch (e: any) {
throw new Error(e.response?.data?.detail || 'Failed to fetch groups')
}
}
public async getGroup(id: number): Promise<GroupDetails> {
try {
return await this.repository.getGroup(id)
} catch (e: any) {
throw new Error(e.response?.data?.detail || `Failed to fetch group with ID ${id}`)
}
}
public async createGroup(data: { name: string, permissions?: number[] }): Promise<Group> {
try {
return await this.repository.createGroup(data)
} catch (e: any) {
throw new Error(e.response?.data?.detail || 'Failed to create group')
}
}
public async updateGroup(id: number, data: { name?: string, permissions?: number[] }): Promise<Group> {
try {
return await this.repository.updateGroup(id, data)
} catch (e: any) {
throw new Error(e.response?.data?.detail || `Failed to update group with ID ${id}`)
}
}
public async deleteGroup(id: number): Promise<void> {
try {
await this.repository.deleteGroup(id)
} catch (e: any) {
throw new Error(e.response?.data?.detail || `Failed to delete group with ID ${id}`)
}
}
// Permission methods
public async listPermissions(query: string = ''): Promise<PaginatedResponse<Permission>> {
try {
return await this.repository.listPermissions(query)
} catch (e: any) {
throw new Error(e.response?.data?.detail || 'Failed to fetch permissions')
}
}
public async getPermission(id: number): Promise<Permission> {
try {
return await this.repository.getPermission(id)
} catch (e: any) {
throw new Error(e.response?.data?.detail || `Failed to fetch permission with ID ${id}`)
}
}
// ContentType methods
public async listContentTypes(query: string = ''): Promise<PaginatedResponse<ContentType>> {
try {
return await this.repository.listContentTypes(query)
} catch (e: any) {
throw new Error(e.response?.data?.detail || 'Failed to fetch content types')
}
}
public async getContentType(id: number): Promise<ContentType> {
try {
return await this.repository.getContentType(id)
} catch (e: any) {
throw new Error(e.response?.data?.detail || `Failed to fetch content type with ID ${id}`)
}
}
}

1
frontend/services/application/user/UserApplicationService.ts

@ -5,6 +5,7 @@ type UserUpdateFields = {
username?: string
is_superuser?: boolean
is_staff?: boolean
groups?: number[]
[key: string]: any
}

6
frontend/store/auth.js

@ -76,14 +76,14 @@ export const actions = {
commit('setIsStaff', false)
commit('clearUsername')
},
async registerUser(_,authData) {
async registerUser(_, authData) {
console.log('authData', authData)
try {
await this.$repositories.auth.register(
authData.username,
authData.email,
authData.password1,
authData.password2
authData.password,
authData.passwordConfirm
)
} catch (error) {
throw new Error('Failed to register user')

Loading…
Cancel
Save