Compare commits
2 commits
d6df034ad0
...
d1626d1777
Author | SHA1 | Date | |
---|---|---|---|
d1626d1777 | |||
5a1de437b6 |
8 changed files with 132 additions and 21 deletions
|
@ -88,6 +88,12 @@ class IssueViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
|
|
||||||
class CommentSerializer(serializers.ModelSerializer):
|
class CommentSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if 'comment' not in attrs or attrs['comment'] == '':
|
||||||
|
raise serializers.ValidationError('comment cannot be empty')
|
||||||
|
return attrs
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Comment
|
model = Comment
|
||||||
fields = ('id', 'comment', 'timestamp', 'issue_thread')
|
fields = ('id', 'comment', 'timestamp', 'issue_thread')
|
||||||
|
@ -176,12 +182,33 @@ def get_available_states(request):
|
||||||
return Response(get_state_choices())
|
return Response(get_state_choices())
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
@permission_required('tickets.add_comment', raise_exception=True)
|
||||||
|
def add_comment(request, pk):
|
||||||
|
issue = IssueThread.objects.get(pk=pk)
|
||||||
|
if 'comment' not in request.data or request.data['comment'] == '':
|
||||||
|
return Response({'status': 'error', 'message': 'missing comment'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
comment = Comment.objects.create(
|
||||||
|
issue_thread=issue,
|
||||||
|
comment=request.data['comment'],
|
||||||
|
)
|
||||||
|
systemevent = SystemEvent.objects.create(type='comment added', reference=comment.id)
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||||
|
"message": "comment added"}
|
||||||
|
)
|
||||||
|
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(r'tickets', IssueViewSet, basename='issues')
|
router.register(r'tickets', IssueViewSet, basename='issues')
|
||||||
router.register(r'comments', CommentViewSet, basename='comments')
|
router.register(r'comments', CommentViewSet, basename='comments')
|
||||||
|
|
||||||
urlpatterns = ([
|
urlpatterns = ([
|
||||||
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
|
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
|
||||||
|
re_path(r'^tickets/(?P<pk>\d+)/comment/$', add_comment, name='add_comment'),
|
||||||
re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
|
re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
|
||||||
re_path(r'^tickets/states/$', get_available_states, name='get_available_states'),
|
re_path(r'^tickets/states/$', get_available_states, name='get_available_states'),
|
||||||
] + router.urls)
|
] + router.urls)
|
||||||
|
|
|
@ -175,6 +175,23 @@ class IssueApiTest(TestCase):
|
||||||
self.assertEqual(response.json()['issue_thread'], issue.id)
|
self.assertEqual(response.json()['issue_thread'], issue.id)
|
||||||
self.assertEqual(response.json()['timestamp'], response.json()['timestamp'])
|
self.assertEqual(response.json()['timestamp'], response.json()['timestamp'])
|
||||||
|
|
||||||
|
def test_post_comment_altenative(self):
|
||||||
|
issue = IssueThread.objects.create(
|
||||||
|
name="test issue",
|
||||||
|
)
|
||||||
|
response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': 'test'})
|
||||||
|
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_post_alt_comment_empty(self):
|
||||||
|
issue = IssueThread.objects.create(
|
||||||
|
name="test issue",
|
||||||
|
)
|
||||||
|
response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': ''})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_state_change(self):
|
def test_state_change(self):
|
||||||
issue = IssueThread.objects.create(
|
issue = IssueThread.objects.create(
|
||||||
name="test issue",
|
name="test issue",
|
||||||
|
|
|
@ -148,6 +148,7 @@ export default {
|
||||||
padding-bottom: 1rem !important;
|
padding-bottom: 1rem !important;
|
||||||
border: var(--gray) solid 1px !important;
|
border: var(--gray) solid 1px !important;
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
|
color: var(--blue) !important;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: black !important;
|
background: black !important;
|
||||||
|
|
|
@ -23,12 +23,31 @@
|
||||||
<span class="timeline-item-icon | faded-icon">
|
<span class="timeline-item-icon | faded-icon">
|
||||||
<font-awesome-icon icon="comment"/>
|
<font-awesome-icon icon="comment"/>
|
||||||
</span>
|
</span>
|
||||||
<div class="new-comment">
|
<div class="new-comment card bg-dark">
|
||||||
<div class="input-group">
|
<div class="">
|
||||||
<textarea placeholder="reply mail..." v-model="newMail">
|
<textarea placeholder="add comment..." v-model="newComment" class="form-control">
|
||||||
</textarea>
|
</textarea>
|
||||||
<button class="btn btn-primary" @click="sendMailandClear">
|
<button class="btn btn-primary float-right" @click="addCommentAndClear">
|
||||||
Send
|
<font-awesome-icon icon="comment"/>
|
||||||
|
Save Comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="timeline-item">
|
||||||
|
<span class="timeline-item-icon | faded-icon">
|
||||||
|
<font-awesome-icon icon="envelope"/>
|
||||||
|
</span>
|
||||||
|
<div class="new-mail card bg-dark">
|
||||||
|
<div class="card-header">
|
||||||
|
{{ newestMailSubject }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
||||||
|
</textarea>
|
||||||
|
<button class="btn btn-primary float-right" @click="sendMailAndClear">
|
||||||
|
<font-awesome-icon icon="envelope"/>
|
||||||
|
Send Mail
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,18 +71,27 @@ export default {
|
||||||
default: () => []
|
default: () => []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['sendMail'],
|
emits: ['sendMail', 'addComment'],
|
||||||
data: () => ({
|
data: () => ({
|
||||||
newMail: ""
|
newMail: "",
|
||||||
|
newComment: ""
|
||||||
}),
|
}),
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['stateInfo']),
|
...mapGetters(['stateInfo']),
|
||||||
|
newestMailSubject() {
|
||||||
|
const mail = this.timeline.filter(item => item.type === 'mail').pop();
|
||||||
|
return mail ? mail.subject : "";
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
sendMailandClear: function () {
|
sendMailAndClear: function () {
|
||||||
this.$emit('sendMail', this.newMail);
|
this.$emit('sendMail', this.newMail);
|
||||||
this.newMail = "";
|
this.newMail = "";
|
||||||
},
|
},
|
||||||
|
addCommentAndClear: function () {
|
||||||
|
this.$emit('addComment', this.newComment);
|
||||||
|
this.newComment = "";
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -117,14 +145,14 @@ img {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-comment {
|
.new-comment, .new-mail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
input {
|
textarea, input {
|
||||||
border: 1px solid var(--gray);
|
border: 1px solid var(--gray);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
height: 48px;
|
height: 5em;
|
||||||
padding: 0 16px;
|
padding: 8px 16px;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: var(--gray-dark);
|
color: var(--gray-dark);
|
||||||
|
|
18
web/src/components/inputs/ClipboardButton.vue
Normal file
18
web/src/components/inputs/ClipboardButton.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<button @click="fillClipboard" class="btn" :title="payload">
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ClipboardButton',
|
||||||
|
props: ['payload'],
|
||||||
|
methods: {
|
||||||
|
fillClipboard() {
|
||||||
|
navigator.clipboard.writeText(this.payload);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
|
@ -36,7 +36,7 @@ import {
|
||||||
faUser,
|
faUser,
|
||||||
faComments,
|
faComments,
|
||||||
faArchive,
|
faArchive,
|
||||||
faMinus, faHourglass, faExclamation,
|
faMinus, faHourglass, faExclamation, faClipboard,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
|
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ import vueDebounce from 'vue-debounce';
|
||||||
|
|
||||||
library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList,
|
library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList,
|
||||||
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope,
|
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope,
|
||||||
faArchive, faMinus, faExclamation, faHourglass);
|
faArchive, faMinus, faExclamation, faHourglass, faClipboard);
|
||||||
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
Vue.component('font-awesome-icon', FontAwesomeIcon);
|
||||||
|
|
||||||
sync(store, router);
|
sync(store, router);
|
||||||
|
|
|
@ -35,9 +35,7 @@ axios.interceptors.response.use(response => response, error => {
|
||||||
store.commit('createToast', {title: 'Error: Access denied', message, color: 'danger'});
|
store.commit('createToast', {title: 'Error: Access denied', message, color: 'danger'});
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
} else {
|
} else {
|
||||||
console.log('error interceptor fired');
|
console.error('error interceptor fired', error.message);
|
||||||
console.error(error); // todo: toast error
|
|
||||||
console.log(Object.entries(error));
|
|
||||||
|
|
||||||
if (error.isAxiosError) {
|
if (error.isAxiosError) {
|
||||||
const message = `
|
const message = `
|
||||||
|
@ -272,6 +270,7 @@ const store = new Vuex.Store({
|
||||||
//credentials failed, logout
|
//credentials failed, logout
|
||||||
store.commit('logout');
|
store.commit('logout');
|
||||||
},
|
},
|
||||||
|
//async verifyToken({commit, state}) {
|
||||||
async afterLogin({dispatch}) {
|
async afterLogin({dispatch}) {
|
||||||
const boxes = dispatch('loadBoxes');
|
const boxes = dispatch('loadBoxes');
|
||||||
const items = dispatch('loadEventItems');
|
const items = dispatch('loadEventItems');
|
||||||
|
@ -375,6 +374,10 @@ const store = new Vuex.Store({
|
||||||
});
|
});
|
||||||
await dispatch('loadTickets');
|
await dispatch('loadTickets');
|
||||||
},
|
},
|
||||||
|
async postComment({commit, dispatch}, {id, message}) {
|
||||||
|
const {data} = await axios.post(`/2/tickets/${id}/comment/`, {comment: message});
|
||||||
|
await dispatch('loadTickets');
|
||||||
|
},
|
||||||
async loadUsers({commit}) {
|
async loadUsers({commit}) {
|
||||||
const {data} = await axios.get('/2/users/');
|
const {data} = await axios.get('/2/users/');
|
||||||
commit('replaceUsers', data);
|
commit('replaceUsers', data);
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
|
<h3>Ticket #{{ ticket.id }} - {{ ticket.name }}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Timeline :timeline="ticket.timeline" @sendMail="handleMail"/>
|
<Timeline :timeline="ticket.timeline" @sendMail="handleMail" @addComment="handleComment"/>
|
||||||
<div class="card-footer d-flex justify-content-between">
|
<div class="card-footer d-flex justify-content-between">
|
||||||
<router-link :to="{name: 'tickets'}" class="btn btn-secondary mr-2">Back</router-link>
|
<router-link :to="{name: 'tickets'}" class="btn btn-secondary mr-2">Back</router-link>
|
||||||
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
|
<!--button class="btn btn-danger" @click="deleteItem({type: 'tickets', id: ticket.id})">
|
||||||
|
@ -16,11 +16,17 @@
|
||||||
<button-- class="btn btn-success" @click="markItemReturned({type: 'tickets', id: ticket.id})">Mark
|
<button-- class="btn btn-success" @click="markItemReturned({type: 'tickets', id: ticket.id})">Mark
|
||||||
as returned
|
as returned
|
||||||
</button-->
|
</button-->
|
||||||
|
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
|
||||||
|
<font-awesome-icon icon="clipboard"/>
|
||||||
|
Copy DHL contact to clipboard
|
||||||
|
</ClipboardButton>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<select class="form-control" v-model="ticket.state">
|
<select class="form-control" v-model="ticket.state">
|
||||||
<option v-for="status in state_options" :value="status.value">{{ status.text }}</option>
|
<option v-for="status in state_options" :value="status.value">{{ status.text }}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="form-control btn btn-success" @click="changeTicketStatus(ticket)">Change Status</button>
|
<button class="form-control btn btn-success" @click="changeTicketStatus(ticket)">
|
||||||
|
Change Status
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -32,26 +38,37 @@
|
||||||
<script>
|
<script>
|
||||||
import {mapActions, mapState} from 'vuex';
|
import {mapActions, mapState} from 'vuex';
|
||||||
import Timeline from "@/components/Timeline.vue";
|
import Timeline from "@/components/Timeline.vue";
|
||||||
|
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Ticket',
|
name: 'Ticket',
|
||||||
components: {Timeline},
|
components: {ClipboardButton, Timeline},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['tickets', 'state_options']),
|
...mapState(['tickets', 'state_options']),
|
||||||
ticket() {
|
ticket() {
|
||||||
const id = parseInt(this.$route.params.id)
|
const id = parseInt(this.$route.params.id)
|
||||||
const ret = this.tickets.find(ticket => ticket.id === id);
|
const ret = this.tickets.find(ticket => ticket.id === id);
|
||||||
return ret ? ret : {};
|
return ret ? ret : {};
|
||||||
|
},
|
||||||
|
shippingEmail() {
|
||||||
|
const domain = document.location.hostname;
|
||||||
|
return `ticket+${this.ticket.uuid}@${domain}`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['deleteItem', 'markItemReturned', 'loadTickets', 'sendMail', 'updateTicketPartial', 'fetchTicketStates']),
|
...mapActions(['deleteItem', 'markItemReturned', 'loadTickets', 'sendMail', 'updateTicketPartial', 'fetchTicketStates', 'postComment']),
|
||||||
handleMail(mail) {
|
handleMail(mail) {
|
||||||
this.sendMail({
|
this.sendMail({
|
||||||
id: this.ticket.id,
|
id: this.ticket.id,
|
||||||
message: mail
|
message: mail
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
handleComment(comment) {
|
||||||
|
this.postComment({
|
||||||
|
id: this.ticket.id,
|
||||||
|
message: comment
|
||||||
|
})
|
||||||
|
},
|
||||||
changeTicketStatus(ticket) {
|
changeTicketStatus(ticket) {
|
||||||
this.updateTicketPartial({
|
this.updateTicketPartial({
|
||||||
id: ticket.id,
|
id: ticket.id,
|
||||||
|
|
Loading…
Reference in a new issue