From b66df406972ce40d94e0c66921d841f54e725826 Mon Sep 17 00:00:00 2001 From: Brent Silkyn <160223373+bsilkyn@users.noreply.github.com> Date: Wed, 1 May 2024 10:20:25 +0200 Subject: [PATCH] feat: Admin docker images (#345) * chore: added ability to upload docker images as an admin #334 * chore: actually add the actual view of admin panel to add docker images #334 * chore: lint fixes #334 * chore: rename url path and name to remove underscores #334 * chore: update translations and add public option docker image add admin panel #334 * chore: lint fix #334 * chore: start of a listing of docker images * chore: add docker type #369 #334 * chore: add search endpoint in frontend + abstract the lazy data table #369 * chore: fully fix abstract Lazy Paginator Data Table and adjust UsersView.vue * chore: frontend listing of docker images #369 * chore: backend search implementation (filter not fully functional) + translations and column change #369 * chore: try adding edit of public status (right now non-functional) #369 * chore: docker images edit button * chore: filters fixed + public status editing fixed #369 * chore: lint fixes + filter background fix #369 * chore: lint fixes fix #369 * chore: add translations * chore: fix wrong endpoint (sorryyyyy; well spotted though) --- backend/api/views/docker_view.py | 49 +++++- frontend/src/assets/lang/app/en.json | 17 +- frontend/src/assets/lang/app/nl.json | 17 +- .../src/components/admin/LazyDataTable.vue | 110 ++++++++++++ .../components/layout/admin/AdminSidebar.vue | 9 + .../composables/services/docker.service.ts | 64 +++++++ frontend/src/config/endpoints.ts | 5 + frontend/src/router/router.ts | 2 + frontend/src/types/DockerImage.ts | 20 +++ frontend/src/types/filter/Filter.ts | 20 +++ frontend/src/views/admin/DockerImagesView.vue | 158 ++++++++++++++++++ frontend/src/views/admin/UsersView.vue | 90 ++-------- 12 files changed, 479 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/admin/LazyDataTable.vue create mode 100644 frontend/src/composables/services/docker.service.ts create mode 100644 frontend/src/types/DockerImage.ts create mode 100644 frontend/src/views/admin/DockerImagesView.vue diff --git a/backend/api/views/docker_view.py b/backend/api/views/docker_view.py index 769de796..fb1b0346 100644 --- a/backend/api/views/docker_view.py +++ b/backend/api/views/docker_view.py @@ -1,23 +1,66 @@ from api.models.docker import DockerImage from api.permissions.docker_permissions import DockerPermission -from api.permissions.role_permissions import IsAssistant, IsTeacher from api.serializers.docker_serializer import DockerImageSerializer +from rest_framework.permissions import IsAdminUser from django.db.models import Q from django.db.models.manager import BaseManager from rest_framework.decorators import action from rest_framework.mixins import (CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, UpdateModelMixin) -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet +from api.views.pagination.basic_pagination import BasicPagination + class DockerImageViewSet(RetrieveModelMixin, CreateModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet): queryset = DockerImage.objects.all() serializer_class = DockerImageSerializer - permission_classes = [DockerPermission] + permission_classes = [DockerPermission, IsAdminUser] + + @action(detail=False) + def search(self, request: Request) -> Response: + self.pagination_class = BasicPagination + + search = request.query_params.get("search", "") + identifier = request.query_params.get("id", "") + name = request.query_params.get("name", "") + owner = request.query_params.get("owner", "") + + queryset1 = self.get_queryset().filter( + id__icontains=search + ) + queryset2 = self.get_queryset().filter( + name__icontains=search + ) + queryset3 = self.get_queryset().filter( + owner__id__icontains=search + ) + queryset1 = queryset1.union(queryset2, queryset3) + queryset = self.get_queryset().filter( + id__icontains=identifier, + name__icontains=name, + owner__id__contains=owner + ) + queryset = queryset.intersection(queryset1) + + serializer = self.serializer_class(self.paginate_queryset(queryset), many=True, context={ + "request": request + }) + + return self.get_paginated_response(serializer.data) + + @action(detail=True, methods=['PATCH'], url_path='public', permission_classes=[IsAdminUser]) + def patch_public(self, request: Request, **_) -> Response: + docker_image = self.get_object() + serializer = DockerImageSerializer(docker_image, data=request.data, partial=True, context={"request": request}) + + if serializer.is_valid(): + serializer.save() + + return Response(serializer.data) # TODO: Maybe not necessary # https://www.django-rest-framework.org/api-guide/permissions/#overview-of-access-restriction-methods diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json index c68e83c5..133d76cd 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -231,6 +231,9 @@ "admin": { "title": "Admin", "keyword": "Keyword", + "id": "ID", + "list": "List", + "add": "Add", "search": { "search": "Search", "general": "Search by general keyword" @@ -240,7 +243,6 @@ "save": "Save", "users": { "title": "Users", - "id": "ID", "username": "Username", "email": "Email", "roles": "Roles" @@ -257,7 +259,18 @@ "teachers": { "title": "Teachers" }, - "teacher": "Teacher" + "teacher": "Teacher", + "catalog": "Catalog", + "docker_images": { + "title": "Docker Images", + "name_input": "Name of docker image", + "name": "Name", + "owner": "Owner ID", + "public": "Public", + "private": "Private" + }, + "none_found": "No matching data.", + "loading": "Loading data. Please wait." }, "primevue": { "startsWith": "Starts with", diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index a7ff3470..7c8bbc24 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -231,6 +231,9 @@ "admin": { "title": "Beheerder", "keyword": "Trefwoord", + "id": "ID", + "list": "Lijst", + "add": "Voeg toe", "search": { "search": "Zoeken", "general": "Zoek op algemeen trefwoord" @@ -240,7 +243,6 @@ "save": "Sla op", "users": { "title": "Gebruikers", - "id": "ID", "username": "Gebruikersnaam", "email": "E-mail", "roles": "Functies" @@ -257,7 +259,18 @@ "teachers": { "title": "Proffen" }, - "teacher": "Prof" + "teacher": "Prof", + "catalog": "Catalogus", + "docker_images": { + "title": "Docker Images", + "name_input": "Naam van docker image", + "name": "Naam", + "owner": "Eigenaar ID", + "public": "Publiek", + "private": "Privaat" + }, + "none_found": "Geen overeenkomende data gevonden.", + "loading": "Aan het laden. Wacht een momentje aub." }, "primevue": { "accept": "Ja", diff --git a/frontend/src/components/admin/LazyDataTable.vue b/frontend/src/components/admin/LazyDataTable.vue new file mode 100644 index 00000000..1781d9b7 --- /dev/null +++ b/frontend/src/components/admin/LazyDataTable.vue @@ -0,0 +1,110 @@ + + + + + + + + + {{ t('admin.none_found') }} + {{ t('admin.loading') }} + + + + + + + diff --git a/frontend/src/components/layout/admin/AdminSidebar.vue b/frontend/src/components/layout/admin/AdminSidebar.vue index 127e8186..729936f9 100644 --- a/frontend/src/components/layout/admin/AdminSidebar.vue +++ b/frontend/src/components/layout/admin/AdminSidebar.vue @@ -15,6 +15,15 @@ const items = ref([ }, ], }, + { + label: 'admin.catalog', + items: [ + { + label: 'admin.docker_images.title', + route: 'admin-dockerImages', + }, + ], + }, ]); diff --git a/frontend/src/composables/services/docker.service.ts b/frontend/src/composables/services/docker.service.ts new file mode 100644 index 00000000..69b25946 --- /dev/null +++ b/frontend/src/composables/services/docker.service.ts @@ -0,0 +1,64 @@ +import { DockerImage } from '@/types/DockerImage.ts'; +import { Response } from '@/types/Response.ts'; +import { endpoints } from '@/config/endpoints.ts'; +import { type Ref, ref } from 'vue'; +import { type Filter } from '@/types/filter/Filter.ts'; +import { create, getList, getPaginatedList, patch } from '@/composables/services/helpers.ts'; +import { type PaginatorResponse } from '@/types/filter/Paginator.ts'; + +interface DockerImagesState { + pagination: Ref | null>; + dockerImages: Ref; + response: Ref; + getDockerImages: () => Promise; + searchDockerImages: (filters: Filter, page: number, pageSize: number) => Promise; + patchDockerImage: (dockerData: DockerImage) => Promise; + createDockerImage: (dockerData: DockerImage, file: File) => Promise; +} + +export function useDockerImages(): DockerImagesState { + const pagination = ref | null>(null); + const dockerImages = ref(null); + const response = ref(null); + + async function getDockerImages(): Promise { + const endpoint = endpoints.dockerImages.index; + await getList(endpoint, dockerImages, DockerImage.fromJSON); + } + + async function searchDockerImages(filters: Filter, page: number, pageSize: number): Promise { + const endpoint = endpoints.dockerImages.search; + await getPaginatedList(endpoint, filters, page, pageSize, pagination, DockerImage.fromJSON); + } + + async function patchDockerImage(dockerData: DockerImage): Promise { + const endpoint = endpoints.dockerImages.patch.replace('{id}', dockerData.id); + await patch(endpoint, { public: dockerData.public }, response); + } + + async function createDockerImage(dockerData: DockerImage, file: File): Promise { + const endpoint = endpoints.dockerImages.index; + await create( + endpoint, + { + file, + name: dockerData.name, + public: dockerData.public, + }, + response, + Response.fromJSON, + 'multipart/form-data', + ); + } + + return { + pagination, + dockerImages, + response, + + getDockerImages, + searchDockerImages, + patchDockerImage, + createDockerImage, + }; +} diff --git a/frontend/src/config/endpoints.ts b/frontend/src/config/endpoints.ts index ad8e8435..4ab98e01 100644 --- a/frontend/src/config/endpoints.ts +++ b/frontend/src/config/endpoints.ts @@ -18,6 +18,11 @@ export const endpoints = { byTeacher: '/api/teachers/{teacherId}/courses/', byAssistant: '/api/assistants/{assistantId}/courses/', }, + dockerImages: { + index: '/api/docker-images/', + search: '/api/docker-images/search/', + patch: '/api/docker-images/{id}/public/', + }, students: { index: '/api/students/', retrieve: '/api/students/{id}/', diff --git a/frontend/src/router/router.ts b/frontend/src/router/router.ts index 13db7315..68955715 100644 --- a/frontend/src/router/router.ts +++ b/frontend/src/router/router.ts @@ -21,6 +21,7 @@ import SubmissionView from '@/views/submissions/SubmissionView.vue'; import AdminView from '@/views/admin/AdminView.vue'; import UsersView from '@/views/admin/UsersView.vue'; import ProjectsView from '@/views/projects/ProjectsView.vue'; +import DockerImagesView from '@/views/admin/DockerImagesView.vue'; const routes: RouteRecordRaw[] = [ // Authentication @@ -151,6 +152,7 @@ const routes: RouteRecordRaw[] = [ children: [ { path: '', component: AdminView, name: 'admin' }, { path: 'users', component: UsersView, name: 'admin-users' }, + { path: 'docker-images', component: DockerImagesView, name: 'admin-dockerImages' }, ], }, diff --git a/frontend/src/types/DockerImage.ts b/frontend/src/types/DockerImage.ts new file mode 100644 index 00000000..9d82a49f --- /dev/null +++ b/frontend/src/types/DockerImage.ts @@ -0,0 +1,20 @@ +export class DockerImage { + public public: boolean; + constructor( + public id: string, + public name: string, + public file: string, // in the form of a uri + public publicStatus: boolean, + public owner: string, + ) { + this.public = publicStatus; + } + + static fromJSON(dockerData: DockerImage): DockerImage { + return new DockerImage(dockerData.id, dockerData.name, dockerData.file, dockerData.public, dockerData.owner); + } + + static blankDockerImage(): DockerImage { + return new DockerImage('', '', '', false, ''); + } +} diff --git a/frontend/src/types/filter/Filter.ts b/frontend/src/types/filter/Filter.ts index 10358711..59236fd3 100644 --- a/frontend/src/types/filter/Filter.ts +++ b/frontend/src/types/filter/Filter.ts @@ -14,6 +14,12 @@ export type CourseFilter = { years: string[]; } & Filter; +export type DockerImageFilter = { + id: string; + name: string; + owner: string; +} & Filter; + export interface Filter { search: string; [key: string]: string | string[]; @@ -69,6 +75,20 @@ export function getCourseFilters(query: LocationQuery): CourseFilter { return filters; } +/** + * Get the docker image filters from the query. + * + * @param query + */ +export function getDockerImageFilters(query: LocationQuery): DockerImageFilter { + return { + search: query.search?.toString() ?? '', + id: query.id?.toString() ?? '', + name: query.id?.toString() ?? '', + owner: query.id?.toString() ?? '', + }; +} + /** * Get the query list. * diff --git a/frontend/src/views/admin/DockerImagesView.vue b/frontend/src/views/admin/DockerImagesView.vue new file mode 100644 index 00000000..f1aa089e --- /dev/null +++ b/frontend/src/views/admin/DockerImagesView.vue @@ -0,0 +1,158 @@ + + + + + + {{ t('admin.docker_images.title') }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + toggleSafetyGuard(data)" + :on-label="t('admin.docker_images.public')" + :off-label="t('admin.docker_images.private')" + /> + + + + + + + + + {{ t('admin.docker_images.public') }} + + + + + No file selected. + + + + + + + + Are you sure? + + + changePublicStatus(editItem)" /> + + + + + diff --git a/frontend/src/views/admin/UsersView.vue b/frontend/src/views/admin/UsersView.vue index 0d97de75..8118dd25 100644 --- a/frontend/src/views/admin/UsersView.vue +++ b/frontend/src/views/admin/UsersView.vue @@ -1,5 +1,4 @@