From 0021cf694e6e8778ef2bd530b8c64f008e8d4f5a Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Bussignies <jb@cri.epita.fr> Date: Tue, 14 Feb 2023 16:59:27 +0100 Subject: [PATCH 1/2] fleet: add view and serializer for issues --- fleet/serializers.py | 24 +++++++++++++++++++++ fleet/views.py | 50 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/fleet/serializers.py b/fleet/serializers.py index 1f8c9f3..fc0c296 100644 --- a/fleet/serializers.py +++ b/fleet/serializers.py @@ -20,6 +20,30 @@ class IssueSerializer(serializers.HyperlinkedModelSerializer): ) +class SelfIssueSerializer(serializers.HyperlinkedModelSerializer): + workstation = serializers.SerializerMethodField() + + class Meta: + model = Issue + fields = ( + "url", + "id", + "workstation", + "description", + "problem_type", + "severity", + "status", + "added_at", + ) + + def get_workstation(self, obj): + return { + "id": obj.workstation.id, + "name": obj.workstation.name, + "switch_name": obj.workstation.switch_name, + } + + class MachineSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = Machine diff --git a/fleet/views.py b/fleet/views.py index f5e8ceb..6e9d0dd 100644 --- a/fleet/views.py +++ b/fleet/views.py @@ -7,8 +7,9 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView, View from rest_framework.decorators import action -from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly -from rest_framework.viewsets import ModelViewSet +from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly, IsAdminUser +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet, ViewSet from .filters import IssueFilter from .forms import RegisterForm @@ -20,6 +21,7 @@ from .serializers import ( RoomSerializer, SiteSerializer, WorkstationSerializer, + SelfIssueSerializer, ) @@ -115,7 +117,8 @@ class DhcpConfigDiscovery(View): }, { "name": "ipxe_legacy_x64", - "test": "not option[client-system].hex == 0x0006 and not option[client-system].hex == 0x0007 and not option[client-system].hex == 0x0009", # noqa: E501 + "test": "not option[client-system].hex == 0x0006 and not option[client-system].hex == 0x0007 and not option[client-system].hex == 0x0009", + # noqa: E501 "boot-file-name": "undionly.kpxe", }, ], @@ -126,7 +129,8 @@ class DhcpConfigDiscovery(View): "subnet": str(subnet.subnet), "pools": [ { - "pool": f"{subnet.allocation_pool_start} - {subnet.allocation_pool_end}" # noqa: E501 + "pool": f"{subnet.allocation_pool_start} - {subnet.allocation_pool_end}" + # noqa: E501 } ], "option-data": [ @@ -265,6 +269,26 @@ class RoomViewSet(ModelViewSet): permission_classes = [DjangoModelWithViewPermissions] filterset_fields = ("site",) + @action(detail=True, methods=["get"]) + def issues(self, request, *args, **kwargs): + queryset = Issue.objects.filter( + workstation__room=self.get_object(), status__in=["new", "confirmed"] + ) + serializer = SelfIssueSerializer( + queryset, many=True, context={"request": request} + ) + return Response(serializer.data) + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + if self.action in ["issues"]: + permission_classes = [IsAdminUser] + else: + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + return [permission() for permission in permission_classes] + class WorkstationViewSet(ModelViewSet): queryset = Workstation.objects.all() @@ -299,3 +323,21 @@ class IssueViewSet(ModelViewSet): def resolve(self, request, *args, **kwargs): self.get_object().resolve(request.user, save=True) return self.retrieve(request) + + @action(detail=False, methods=["get"]) + def self(self, request, *args, **kwargs): + queryset = Issue.objects.filter(added_by=request.user).order_by("-added_at") + serializer = SelfIssueSerializer( + queryset, many=True, context={"request": request} + ) + return Response(serializer.data) + + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + """ + if self.action in ["confirm", "reject", "resolve"]: + permission_classes = [IsAdminUser] + else: + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + return [permission() for permission in permission_classes] -- GitLab From fe78453cbca4b8aa12516aabee47dfb18a77f1a9 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Bussignies <jb@cri.epita.fr> Date: Tue, 14 Feb 2023 16:59:59 +0100 Subject: [PATCH 2/2] frontend: pages: fleet: add issues view --- frontend/pages/fleet/issues.vue | 250 ++++++++++++++++++++++++++++++++ frontend/store/fleet.js | 52 +++++-- 2 files changed, 291 insertions(+), 11 deletions(-) create mode 100644 frontend/pages/fleet/issues.vue diff --git a/frontend/pages/fleet/issues.vue b/frontend/pages/fleet/issues.vue new file mode 100644 index 0000000..7604fe7 --- /dev/null +++ b/frontend/pages/fleet/issues.vue @@ -0,0 +1,250 @@ +<template> + <div> + <b-container fluid> + <b-row> + <b-col sm="6"> + <b-card> + <b-tabs pills vertical> + <b-tab + v-for="site in roomOptions" + :key="'site_' + site.id" + :title="site.label"> + <b-card-text> + <b-form-group v-slot="{ ariaDescribedby }"> + <b-form-checkbox-group + :id="'checkbox-group-' + site.id" + v-model="selectedRooms" + :options="site.options" + :aria-describedby="ariaDescribedby" + stacked switches + class="room-checkbox-group" + @change="refresh"></b-form-checkbox-group> + </b-form-group> + </b-card-text> + </b-tab> + </b-tabs> + </b-card> + </b-col> + <b-col sm="6"> + <b-button-toolbar justify class="float-right"> + <b-button-group> + <b-button variant="outline-primary" @click="refresh"> + <b-icon icon="arrow-clockwise" aria-hidden="true"></b-icon> + </b-button> + </b-button-group> + </b-button-toolbar> + </b-col> + </b-row> + + <div v-for="room in selectedRooms" :key="room.id" class="mt-3"> + <h5>{{ room.name }}</h5> + <b-row> + <b-col + v-for="issue in issuesByRoom(room.id)" :key="issue.id" + sm="12" md="6" lg="4" xl="3"> + <b-card border-variant="secondary" class="mt-2"> + <h4>{{ issue.workstation.name }}</h4> + <b-row> + <b-col class="d-flex inline-flex"> + Type: + <h5 class="pl-2"> + <b-badge>{{ issue.problem_type }}</b-badge> + </h5> + </b-col> + <b-col class="d-flex inline-flex justify-content-end"> + Severity: + <h5 class="pl-2"> + <b-badge :variant="severityVariant(issue.severity)">{{ issue.severity }}</b-badge> + </h5> + </b-col> + </b-row> + <p> + Status: + <b-badge :variant="statusVariant(issue.status)">{{ issue.status }}</b-badge> + </p> + <p class="mb-0"> + <u>Description:</u><br> + {{ issue.description }} + </p> + <b-row class="mt-3 justify-content-end mr-1"> + <b-button + v-if="issue.status === 'new'" + pill + variant="outline-primary" + class="mr-2" + @click="roomId = room.id + issueId = issue.id + handleConfirmIssue()"> + Confirm + </b-button> + <b-button + v-if="issue.status === 'new'" + pill + variant="outline-danger" + class="mr-2" + @click="roomId = room.id + issueId = issue.id + handleRejectIssue()"> + Reject + </b-button> + <b-button + v-if="issue.status === 'confirmed'" + pill + variant="outline-success" + class="mr-2" + @click="roomId = room.id + issueId = issue.id + handleResolveIssue()"> + Resolve + </b-button> + </b-row> + </b-card> + </b-col> + </b-row> + </div> + </b-container> + </div> +</template> + +<script> +import * as errors from '~/utils/errors.js' +import * as toast from '~/utils/toast.js' + +export default { + data() { + return { + selectedRooms: [], + roomId: 0, + issueId: 0, + }; + }, + computed: { + user() { + return this.$store.state.auth.user + }, + sites() { + return this.$store.getters['fleet/sites'] + }, + rooms() { + return this.$store.getters['fleet/roomsBySite'] + }, + roomOptions() { + return Object.keys(this.rooms).map((siteId) => { + if (!(siteId in this.sites)) { + return {} + } + + return { + label: this.sites[siteId].name, + id: siteId, + options: Object.values(this.rooms[siteId]).map((room) => { + return { + value: room, + text: room.name, + } + }), + } + }) + }, + issuesByRoom() { + return (roomId) => { + const data = this.$store.getters['fleet/roomIssues'](roomId); + if (data === undefined) + return []; + return data; + } + }, + severityVariant() { + const variants = { + "unknown": "", + "low": "info", + "medium": "warning", + "high": "danger", + } + return (severity) => { + if (severity in variants) + return variants[severity] + else + return variants.unknown; + } + }, + statusVariant() { + const variants = { + "new": "primary", + "confirmed": "danger", + "rejected": "secondary", + "resolved": "success", + } + return (status) => { + if (status in variants) + return variants[status] + else + return variants.new; + } + }, + }, + created() { + this.refresh(null) + }, + methods: { + refresh(event) { + this.$store.dispatch('fleet/getSites').catch(errors.handleError(this)) + this.$store.dispatch('fleet/getRooms').catch(errors.handleError(this)) + + this.selectedRooms.forEach((room) => { + this.$store + .dispatch('fleet/getIssuesFromRoom', room.id) + .catch(errors.handleError(this)) + }) + }, + handleConfirmIssue() { + toast.toast( + this, + 'Confirming issue', + 'Please wait a few seconds...', + 'info', {autoHideDelay: 200} + ) + + this.$store + .dispatch('fleet/confirmIssue', this.issueId) + .then(() => { + this.$store.dispatch('fleet/getIssuesFromRoom', this.roomId) + toast.toast(this, 'Issue has been confirmed', 'Thank you!', 'success', {autoHideDelay: 200}) + }) + .catch(errors.handleError(this)) + }, + handleRejectIssue() { + toast.toast( + this, + 'Rejecting issue', + 'Please wait a few seconds...', + 'info' + ) + + this.$store + .dispatch('fleet/rejectIssue', this.issueId) + .then(() => { + this.$store.dispatch('fleet/getIssuesFromRoom', this.roomId) + toast.toast(this, 'Issue has been rejected', 'Thank you!', 'success', {autoHideDelay: 200}) + }) + .catch(errors.handleError(this)) + }, + handleResolveIssue() { + toast.toast( + this, + 'Resolving issue', + 'Please wait a few seconds...', + 'info', {autoHideDelay: 200} + ) + + this.$store + .dispatch('fleet/resolveIssue', this.issueId) + .then(() => { + this.$store.dispatch('fleet/getIssuesFromRoom', this.roomId) + toast.toast(this, 'Issue has been resolved', 'Nice job!', 'success', {autoHideDelay: 200}) + }) + .catch(errors.handleError(this)) + }, + }, +} +</script> diff --git a/frontend/store/fleet.js b/frontend/store/fleet.js index a0c6d71..3af9000 100644 --- a/frontend/store/fleet.js +++ b/frontend/store/fleet.js @@ -4,59 +4,61 @@ export const state = () => ({ sites: {}, rooms: {}, workstations: {}, + self_issues: {}, + room_issues: {}, }) export const actions = { - async getSites({ commit }) { + async getSites({commit}) { const url = '/fleet/sites/' const response = await this.$axios.get(url) const sites = response.data.results sites.forEach((site) => commit('site', site)) }, - async getSite({ commit }, siteId) { + async getSite({commit}, siteId) { const url = `/fleet/sites/${siteId}/` const response = await this.$axios.get(url) const site = response.data commit('site', site) }, - async getRooms({ commit }) { + async getRooms({commit}) { const url = `/fleet/rooms/` const response = await this.$axios.get(url) const rooms = response.data.results rooms.forEach((room) => commit('room', room)) }, - async getRoomsBySite({ commit }, siteId) { + async getRoomsBySite({commit}, siteId) { const url = `/fleet/rooms/?site=${siteId}` const response = await this.$axios.get(url) const rooms = response.data.results rooms.forEach((room) => commit('room', room)) }, - async getRoom({ commit }, roomId) { + async getRoom({commit}, roomId) { const url = `/fleet/rooms/${roomId}/` const response = await this.$axios.get(url) const room = response.data commit('room', room) }, - async getWorkstationsByRoom({ commit }, roomId) { + async getWorkstationsByRoom({commit}, roomId) { const url = `/fleet/workstations/?room=${roomId}` const response = await this.$axios.get(url) const workstations = response.data.results workstations.forEach((workstation) => commit('workstation', workstation)) }, - async getWorkstation({ commit }, workstationId) { + async getWorkstation({commit}, workstationId) { const url = `/fleet/workstations/${workstationId}/` const response = await this.$axios.get(url) const workstation = response.data commit('workstation', workstation) }, - async submitIssue({ commit }, data) { + async submitIssue({commit}, data) { const url = '/fleet/issues/' const payload = { workstation: data.workstationUrl, @@ -66,18 +68,32 @@ export const actions = { } await this.$axios.post(url, payload) }, - async confirmIssue({ commit }, issueId) { + async confirmIssue({commit}, issueId) { const url = `/fleet/issues/${issueId}/confirm/` await this.$axios.patch(url) }, - async rejectIssue({ commit }, issueId) { + async rejectIssue({commit}, issueId) { const url = `/fleet/issues/${issueId}/reject/` await this.$axios.patch(url) }, - async resolveIssue({ commit }, issueId) { + async resolveIssue({commit}, issueId) { const url = `/fleet/issues/${issueId}/resolve/` await this.$axios.patch(url) }, + async getSelfIssues({commit}) { + const url = '/fleet/issues/self/' + const response = await this.$axios.get(url) + const issues = response.data + + issues.forEach((issue) => commit('self_issues', issue)) + }, + async getIssuesFromRoom({commit}, roomId) { + const url = `/fleet/rooms/${roomId}/issues/` + const response = await this.$axios.get(url) + const issues = response.data + + commit('room_issues', [roomId, issues]); + }, } export const mutations = { @@ -90,6 +106,14 @@ export const mutations = { workstation(state, workstation) { Vue.set(state.workstations, workstation.id, workstation) }, + self_issues(state, issue) { + Vue.set(state.self_issues, issue.id, issue) + }, + room_issues(state, data) { + const roomId = data[0]; + const issues = data[1]; + Vue.set(state.room_issues, roomId, issues) + } } export const getters = { @@ -122,4 +146,10 @@ export const getters = { }) return workstations }, + selfIssues: (state) => { + return state.self_issues; + }, + roomIssues: (state) => (roomId) => { + return state.room_issues[roomId]; + } } -- GitLab