Compare commits

..

No commits in common. "d1626d177724891c3ba6b38df21293085b1689c1" and "d6df034ad045b20c548b977d8ec51b98c05e9110" have entirely different histories.

8 changed files with 21 additions and 132 deletions

View file

@ -88,12 +88,6 @@ 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')
@ -182,33 +176,12 @@ 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)

View file

@ -175,23 +175,6 @@ 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",

View file

@ -148,7 +148,6 @@ 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;

View file

@ -23,31 +23,12 @@
<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 card bg-dark"> <div class="new-comment">
<div class=""> <div class="input-group">
<textarea placeholder="add comment..." v-model="newComment" class="form-control"> <textarea placeholder="reply mail..." v-model="newMail">
</textarea> </textarea>
<button class="btn btn-primary float-right" @click="addCommentAndClear"> <button class="btn btn-primary" @click="sendMailandClear">
<font-awesome-icon icon="comment"/> Send
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>
@ -71,27 +52,18 @@ export default {
default: () => [] default: () => []
} }
}, },
emits: ['sendMail', 'addComment'], emits: ['sendMail'],
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>
@ -145,14 +117,14 @@ img {
} }
} }
.new-comment, .new-mail { .new-comment {
width: 100%; width: 100%;
textarea, input { input {
border: 1px solid var(--gray); border: 1px solid var(--gray);
border-radius: 6px; border-radius: 6px;
height: 5em; height: 48px;
padding: 8px 16px; padding: 0 16px;
&::placeholder { &::placeholder {
color: var(--gray-dark); color: var(--gray-dark);

View file

@ -1,18 +0,0 @@
<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>

View file

@ -36,7 +36,7 @@ import {
faUser, faUser,
faComments, faComments,
faArchive, faArchive,
faMinus, faHourglass, faExclamation, faClipboard, faMinus, faHourglass, faExclamation,
} 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, faClipboard); faArchive, faMinus, faExclamation, faHourglass);
Vue.component('font-awesome-icon', FontAwesomeIcon); Vue.component('font-awesome-icon', FontAwesomeIcon);
sync(store, router); sync(store, router);

View file

@ -35,7 +35,9 @@ 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.error('error interceptor fired', error.message); console.log('error interceptor fired');
console.error(error); // todo: toast error
console.log(Object.entries(error));
if (error.isAxiosError) { if (error.isAxiosError) {
const message = ` const message = `
@ -270,7 +272,6 @@ 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');
@ -374,10 +375,6 @@ 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);

View file

@ -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" @addComment="handleComment"/> <Timeline :timeline="ticket.timeline" @sendMail="handleMail"/>
<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,17 +16,11 @@
<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)"> <button class="form-control btn btn-success" @click="changeTicketStatus(ticket)">Change Status</button>
Change&nbsp;Status
</button>
</div> </div>
</div> </div>
</div> </div>
@ -38,37 +32,26 @@
<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: {ClipboardButton, Timeline}, components: {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', 'postComment']), ...mapActions(['deleteItem', 'markItemReturned', 'loadTickets', 'sendMail', 'updateTicketPartial', 'fetchTicketStates']),
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,