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