diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py index 695aa7f..7e494e0 100644 --- a/core/tickets/api_v2.py +++ b/core/tickets/api_v2.py @@ -13,7 +13,7 @@ from core.settings import MAIL_DOMAIN from mail.models import Email from mail.protocol import send_smtp, make_reply, collect_references from notify_sessions.models import SystemEvent -from tickets.models import IssueThread +from tickets.models import IssueThread, Comment, STATE_CHOICES, StateChange class IssueSerializer(serializers.ModelSerializer): @@ -53,12 +53,33 @@ class IssueSerializer(serializers.ModelSerializer): }) return sorted(timeline, key=lambda x: x['timestamp']) + def update(self, instance, validated_data): + if 'state' in validated_data: + instance.state = validated_data['state'] + instance.save() + StateChange.objects.create( + issue_thread=instance, + state=validated_data['state'], + ) + return instance + class IssueViewSet(viewsets.ModelViewSet): serializer_class = IssueSerializer queryset = IssueThread.objects.all() +class CommentSerializer(serializers.ModelSerializer): + class Meta: + model = Comment + fields = ('id', 'comment', 'timestamp', 'issue_thread') + + +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = CommentSerializer + queryset = Comment.objects.all() + + @api_view(['POST']) @permission_classes([IsAuthenticated]) @permission_required('tickets.add_issuethread', raise_exception=True) @@ -116,10 +137,32 @@ def manual_ticket(request): return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) +class StateSerializer(serializers.Serializer): + text = serializers.SerializerMethodField() + value = serializers.SerializerMethodField() + + def get_text(self, obj): + return obj['text'] + + def get_value(self, obj): + return obj['value'] + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_available_states(request): + def get_state_choices(): + for state in STATE_CHOICES: + yield {'value': list(state)[0], 'text': list(state)[1]} + return Response(get_state_choices()) + + router = routers.SimpleRouter() router.register(r'tickets', IssueViewSet, basename='issues') +router.register(r'comments', CommentViewSet, basename='comments') urlpatterns = ([ re_path(r'^tickets/(?P\d+)/reply/$', reply, name='reply'), re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'), + re_path(r'^tickets/states/$', get_available_states, name='get_available_states'), ] + router.urls) diff --git a/core/tickets/models.py b/core/tickets/models.py index 7130fe5..a0187c5 100644 --- a/core/tickets/models.py +++ b/core/tickets/models.py @@ -3,11 +3,28 @@ from django_softdelete.models import SoftDeleteModel from inventory.models import Event +STATE_CHOICES = ( + ('pending_new', 'New'), + ('pending_open', 'Open'), + ('pending_shipping', 'Needs to be shipped'), + ('pending_physical_confirmation', 'Needs to be confirmed physically'), + ('pending_return', 'Needs to be returned'), + ('waiting_details', 'Waiting for details'), + ('waiting_pre_shipping', 'Waiting for Address/Shipping Info'), + ('closed_returned', 'Closed: Returned'), + ('closed_shipped', 'Closed: Shipped'), + ('closed_not_found', 'Closed: Not found'), + ('closed_not_our_problem', 'Closed: Not our problem'), + ('closed_duplicate', 'Closed: Duplicate'), + ('closed_timeout', 'Closed: Timeout'), + ('closed_spam', 'Closed: Spam'), +) + class IssueThread(SoftDeleteModel): id = models.AutoField(primary_key=True) name = models.CharField(max_length=255) - state = models.CharField(max_length=255, default='new') + state = models.CharField('state', choices=STATE_CHOICES, max_length=32, default='pending_new') assigned_to = models.CharField(max_length=255, null=True) last_activity = models.DateTimeField(auto_now=True) manually_created = models.BooleanField(default=False) diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py index 1f29963..1f1858d 100644 --- a/core/tickets/tests/v2/test_tickets.py +++ b/core/tickets/tests/v2/test_tickets.py @@ -97,7 +97,7 @@ class IssueApiTest(TestCase): response = self.client.post('/api/2/tickets/manual/', {'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'}) self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()['state'], 'new') + self.assertEqual(response.json()['state'], 'pending_new') self.assertEqual(response.json()['name'], 'test issue') self.assertEqual(response.json()['assigned_to'], None) timeline = response.json()['timeline'] @@ -108,3 +108,35 @@ class IssueApiTest(TestCase): self.assertEqual(timeline[0]['subject'], 'test issue') self.assertEqual(timeline[0]['body'], 'test') + def test_post_comment(self): + issue = IssueThread.objects.create( + name="test issue", + ) + response = self.client.post('/api/2/comments/', {'comment': 'test', 'issue_thread': issue.id}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['comment'], 'test') + self.assertEqual(response.json()['issue_thread'], issue.id) + self.assertEqual(response.json()['timestamp'], response.json()['timestamp']) + + def test_state_change(self): + issue = IssueThread.objects.create( + name="test issue", + ) + response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'pending_open'}, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], 'pending_open') + self.assertEqual(response.json()['name'], 'test issue') + self.assertEqual(response.json()['assigned_to'], None) + timeline = response.json()['timeline'] + self.assertEqual(len(timeline), 1) + self.assertEqual(timeline[0]['type'], 'state') + self.assertEqual(timeline[0]['state'], 'pending_open') + + + def test_state_change_invalid_state(self): + issue = IssueThread.objects.create( + name="test issue", + ) + response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'}, content_type='application/json') + self.assertEqual(response.status_code, 400) + diff --git a/web/src/components/TimelineStateChange.vue b/web/src/components/TimelineStateChange.vue index bccaaf2..481807b 100644 --- a/web/src/components/TimelineStateChange.vue +++ b/web/src/components/TimelineStateChange.vue @@ -4,13 +4,16 @@ $USER has changed state to {{ item.state }} on + class="badge badge-pill badge-primary" :class="'bg-' + colorLookup">{{ lookupState.text }} at + diff --git a/web/src/store/index.js b/web/src/store/index.js index 4a2cc35..99e9e28 100644 --- a/web/src/store/index.js +++ b/web/src/store/index.js @@ -76,6 +76,7 @@ const store = new Vuex.Store({ password: null, userPermissions: [], token: null, + state_options: [], token_expiry: null, local_loaded: false, }, @@ -111,6 +112,9 @@ const store = new Vuex.Store({ replaceEvents(state, events) { state.events = events; }, + replaceTicketStates(state, states) { + state.state_options = states; + }, changeView(state, {view, slug}) { router.push({path: `/${slug}/${view}`}); }, @@ -258,6 +262,10 @@ const store = new Vuex.Store({ const {data} = await axios.get('/2/events/'); commit('replaceEvents', data); }, + async fetchTicketStates({commit}) { + const {data} = await axios.get('/2/tickets/states/'); + commit('replaceTicketStates', data); + }, changeEvent({dispatch, getters, commit}, eventName) { router.push({path: `/${eventName.slug}/${getters.getActiveView}/`}); dispatch('loadEventItems'); @@ -319,7 +327,12 @@ const store = new Vuex.Store({ await dispatch('loadTickets'); }, async postManualTicket({commit, dispatch}, {sender, message, title,}) { - const {data} = await axios.post(`/2/tickets/manual/`, {name: title, sender, body: message, recipient: 'mail@c3lf.de'}); + const {data} = await axios.post(`/2/tickets/manual/`, { + name: title, + sender, + body: message, + recipient: 'mail@c3lf.de' + }); await dispatch('loadTickets'); }, async loadUsers({commit}) { @@ -330,6 +343,14 @@ const store = new Vuex.Store({ const {data} = await axios.get('/2/groups/'); commit('replaceGroups', data); }, + async updateTicket({commit}, ticket) { + const {data} = await axios.put(`/2/tickets/${ticket.id}/`, ticket); + commit('updateTicket', data); + }, + async updateTicketPartial({commit}, {id, ...ticket}) { + const {data} = await axios.patch(`/2/tickets/${id}/`, ticket); + commit('updateTicket', data); + } } }); diff --git a/web/src/views/Items.vue b/web/src/views/Items.vue index 9ed1474..2c927ba 100644 --- a/web/src/views/Items.vue +++ b/web/src/views/Items.vue @@ -88,13 +88,13 @@ export default { computed: mapState(['loadedItems', 'layout']), methods: { ...mapActions(['deleteItem', 'markItemReturned']), - openLightboxModalWith(item) { // Opens the editing modal with a copy of the selected item. + openLightboxModalWith(item) { this.lightboxItem = {...item}; }, closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item. this.lightboxItem = null; }, - openEditingModalWith(item) { + openEditingModalWith(item) { // Opens the editing modal with a copy of the selected item. this.editingItem = item; }, closeEditingModal() { diff --git a/web/src/views/Ticket.vue b/web/src/views/Ticket.vue index c4e064a..e097f5c 100644 --- a/web/src/views/Ticket.vue +++ b/web/src/views/Ticket.vue @@ -9,13 +9,19 @@ @@ -31,7 +37,7 @@ export default { name: 'Ticket', components: {Timeline}, computed: { - ...mapState(['tickets']), + ...mapState(['tickets', 'state_options']), ticket() { const id = parseInt(this.$route.params.id) const ret = this.tickets.find(ticket => ticket.id === id); @@ -39,15 +45,22 @@ export default { } }, methods: { - ...mapActions(['deleteItem', 'markItemReturned', 'loadTickets', 'sendMail']), + ...mapActions(['deleteItem', 'markItemReturned', 'loadTickets', 'sendMail', 'updateTicketPartial', 'fetchTicketStates']), handleMail(mail) { this.sendMail({ id: this.ticket.id, message: mail }) + }, + changeTicketStatus(ticket) { + this.updateTicketPartial({ + id: ticket.id, + state: ticket.state + }) } }, created() { + this.fetchTicketStates() this.loadTickets() } };