Compare commits

...

2 commits

Author SHA1 Message Date
d8be7f09e4 add frontend to manage shipping vouchers 2024-06-23 02:58:31 +02:00
f11758607e add /shipping_vouchers endpoint 2024-06-23 02:52:15 +02:00
11 changed files with 308 additions and 13 deletions

View file

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from tickets.models import IssueThread, Comment, StateChange from tickets.models import IssueThread, Comment, StateChange, Assignment, ShippingVoucher
class IssueThreadAdmin(admin.ModelAdmin): class IssueThreadAdmin(admin.ModelAdmin):
@ -15,6 +15,16 @@ class StateChangeAdmin(admin.ModelAdmin):
pass pass
class AssignmentAdmin(admin.ModelAdmin):
pass
class ShippingVouchersAdmin(admin.ModelAdmin):
pass
admin.site.register(IssueThread, IssueThreadAdmin) admin.site.register(IssueThread, IssueThreadAdmin)
admin.site.register(Comment, CommentAdmin) admin.site.register(Comment, CommentAdmin)
admin.site.register(StateChange, StateChangeAdmin) admin.site.register(StateChange, StateChangeAdmin)
admin.site.register(Assignment, AssignmentAdmin)
admin.site.register(ShippingVoucher, ShippingVouchersAdmin)

View file

@ -13,8 +13,8 @@ from core.settings import MAIL_DOMAIN
from mail.models import Email from mail.models import Email
from mail.protocol import send_smtp, make_reply, collect_references from mail.protocol import send_smtp, make_reply, collect_references
from notify_sessions.models import SystemEvent from notify_sessions.models import SystemEvent
from tickets.models import IssueThread, Comment, STATE_CHOICES from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
from tickets.serializers import IssueSerializer, CommentSerializer from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer
class IssueViewSet(viewsets.ModelViewSet): class IssueViewSet(viewsets.ModelViewSet):
@ -22,6 +22,11 @@ class IssueViewSet(viewsets.ModelViewSet):
queryset = IssueThread.objects.all() queryset = IssueThread.objects.all()
class ShippingVoucherViewSet(viewsets.ModelViewSet):
serializer_class = ShippingVoucherSerializer
queryset = ShippingVoucher.objects.all()
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread', raise_exception=True) @permission_required('tickets.add_issuethread', raise_exception=True)
@ -113,6 +118,7 @@ def add_comment(request, pk):
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues') router.register(r'tickets', IssueViewSet, basename='issues')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
urlpatterns = ([ urlpatterns = ([
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'), re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),

View file

@ -0,0 +1,25 @@
# Generated by Django 4.2.7 on 2024-06-23 00:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tickets', '0008_alter_issuethread_options_and_more'),
]
operations = [
migrations.CreateModel(
name='ShippingVoucher',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('voucher', models.CharField(max_length=255)),
('type', models.CharField(max_length=255)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('used_at', models.DateTimeField(null=True)),
('issue_thread', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shipping_vouchers', to='tickets.issuethread')),
],
),
]

View file

@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.utils import timezone
from django_softdelete.models import SoftDeleteModel from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
@ -116,3 +117,20 @@ class Assignment(models.Model):
def __str__(self): def __str__(self):
return str(self.issue_thread) + ' assigned to ' + self.assigned_to.username return str(self.issue_thread) + ' assigned to ' + self.assigned_to.username
class ShippingVoucher(models.Model):
id = models.AutoField(primary_key=True)
issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='shipping_vouchers', null=True)
voucher = models.CharField(max_length=255)
type = models.CharField(max_length=255)
timestamp = models.DateTimeField(auto_now_add=True)
used_at = models.DateTimeField(null=True)
def __str__(self):
return self.voucher + ' (' + self.type + ')'
def save(self, *args, **kwargs):
if self.used_at is None and self.issue_thread is not None:
self.used_at = timezone.now()
super().save(*args, **kwargs)

View file

@ -2,7 +2,7 @@ from rest_framework import serializers
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
from mail.api_v2 import AttachmentSerializer from mail.api_v2 import AttachmentSerializer
from tickets.models import IssueThread, Comment, STATE_CHOICES from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
class CommentSerializer(serializers.ModelSerializer): class CommentSerializer(serializers.ModelSerializer):
@ -28,6 +28,13 @@ class StateSerializer(serializers.Serializer):
return obj['value'] return obj['value']
class ShippingVoucherSerializer(serializers.ModelSerializer):
class Meta:
model = ShippingVoucher
fields = ('id', 'voucher', 'type', 'timestamp', 'issue_thread', 'used_at')
read_only_fields = ('id', 'timestamp', 'used_at')
class IssueSerializer(serializers.ModelSerializer): class IssueSerializer(serializers.ModelSerializer):
timeline = serializers.SerializerMethodField() timeline = serializers.SerializerMethodField()
last_activity = serializers.SerializerMethodField() last_activity = serializers.SerializerMethodField()
@ -60,7 +67,10 @@ class IssueSerializer(serializers.ModelSerializer):
if self.state_changes.count() > 0 else None if self.state_changes.count() > 0 else None
last_comment = self.comments.order_by('-timestamp').first().timestamp if self.comments.count() > 0 else None last_comment = self.comments.order_by('-timestamp').first().timestamp if self.comments.count() > 0 else None
last_mail = self.emails.order_by('-timestamp').first().timestamp if self.emails.count() > 0 else None last_mail = self.emails.order_by('-timestamp').first().timestamp if self.emails.count() > 0 else None
args = [x for x in [last_state_change, last_comment, last_mail] if x is not None] last_assignment = self.assignments.order_by('-timestamp').first().timestamp if \
self.assignments.count() > 0 else None
args = [x for x in [last_state_change, last_comment, last_mail, last_assignment] if
x is not None]
return max(args) return max(args)
except AttributeError: except AttributeError:
return None return None
@ -100,6 +110,14 @@ class IssueSerializer(serializers.ModelSerializer):
'timestamp': assignment.timestamp, 'timestamp': assignment.timestamp,
'assigned_to': assignment.assigned_to.username, 'assigned_to': assignment.assigned_to.username,
}) })
for shipping_voucher in obj.shipping_vouchers.all():
timeline.append({
'type': 'shipping_voucher',
'id': shipping_voucher.id,
'timestamp': shipping_voucher.used_at,
'voucher': shipping_voucher.voucher,
'voucher_type': shipping_voucher.type,
})
return sorted(timeline, key=lambda x: x['timestamp']) return sorted(timeline, key=lambda x: x['timestamp'])
def get_queryset(self): def get_queryset(self):

View file

@ -0,0 +1,41 @@
from datetime import datetime, timedelta
from django.test import TestCase, Client
from authentication.models import ExtendedUser
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ShippingVoucher
from django.contrib.auth.models import Permission
from knox.models import AuthToken
class ShippingVoucherApiTest(TestCase):
def setUp(self):
super().setUp()
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_issues_empty(self):
response = self.client.get('/api/2/shipping_vouchers/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), [])
def test_issues_list(self):
ShippingVoucher.objects.create(voucher='1234', type='2kg-eu')
response = self.client.get('/api/2/shipping_vouchers/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()[0]['voucher'], '1234')
self.assertEqual(response.json()[0]['used_at'], None)
self.assertEqual(response.json()[0]['issue_thread'], None)
self.assertEqual(response.json()[0]['type'], '2kg-eu')
def test_issues_create(self):
response = self.client.post('/api/2/shipping_vouchers/', {'voucher': '1234', 'type': '2kg-eu'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['voucher'], '1234')
self.assertEqual(response.json()['used_at'], None)
self.assertEqual(response.json()['issue_thread'], None)

View file

@ -15,6 +15,7 @@ import Empty from "@/views/Empty.vue";
import Events from "@/views/admin/Events.vue"; import Events from "@/views/admin/Events.vue";
import AccessControl from "@/views/admin/AccessControl.vue"; import AccessControl from "@/views/admin/AccessControl.vue";
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue" import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
import Shipping from "@/views/admin/Shipping.vue";
const routes = [ const routes = [
{path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}}, {path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}},
@ -69,6 +70,10 @@ const routes = [
path: 'boxes/', name: 'admin_boxes', component: BoxesAdmin, meta: path: 'boxes/', name: 'admin_boxes', component: BoxesAdmin, meta:
{requiresAuth: true, requiresPermission: 'delete_event'} {requiresAuth: true, requiresPermission: 'delete_event'}
}, },
{
path: 'shipping/', name: 'shipping', component: Shipping, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
] ]
}, },
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}}, {path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},

View file

@ -6,7 +6,6 @@ import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils"; import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils";
import sharedStatePlugin from "@/shared-state-plugin"; import sharedStatePlugin from "@/shared-state-plugin";
import persistentStatePlugin from "@/persistent-state-plugin"; import persistentStatePlugin from "@/persistent-state-plugin";
import {triggerRef} from "vue";
const store = createStore({ const store = createStore({
state: { state: {
@ -20,6 +19,8 @@ const store = createStore({
users: [], users: [],
groups: [], groups: [],
state_options: [], state_options: [],
shippingVouchers: [],
lastEvent: '37C3', lastEvent: '37C3',
lastUsed: {}, lastUsed: {},
remember: false, remember: false,
@ -40,12 +41,22 @@ const store = createStore({
users: 0, users: 0,
groups: 0, groups: 0,
states: 0, states: 0,
shippingVouchers: 0,
}, },
persistent_loaded: false, persistent_loaded: false,
shared_loaded: false, shared_loaded: false,
afterInitHandlers: [], afterInitHandlers: [],
showAddBoxModal: false, showAddBoxModal: false,
shippingVoucherTypes: {
'2kg-de': '2kg Paket (DE)',
'5kg-de': '5kg Paket (DE)',
'10kg-de': '10kg Paket (DE)',
'2kg-eu': '2kg Paket (EU)',
'5kg-eu': '5kg Paket (EU)',
'10kg-eu': '10kg Paket (EU)',
}
}, },
getters: { getters: {
route: state => router.currentRoute.value, route: state => router.currentRoute.value,
@ -75,6 +86,12 @@ const store = createStore({
} }
} }
}, },
availableShippingVoucherTypes: state => {
return Object.keys(state.shippingVoucherTypes).map(key => {
var count = state.shippingVouchers.filter(voucher => voucher.type === key && voucher.issue_thread === null).length;
return {id: key, count: count, name: state.shippingVoucherTypes[key]};
});
},
layout: (state, getters) => { layout: (state, getters) => {
if (router.currentRoute.value.query.layout) if (router.currentRoute.value.query.layout)
return router.currentRoute.value.query.layout; return router.currentRoute.value.query.layout;
@ -139,7 +156,6 @@ const store = createStore({
updateTicket(state, updatedTicket) { updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0]; const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket); Object.assign(ticket, updatedTicket);
//triggerRef(state.tickets);
state.tickets = [...state.tickets]; state.tickets = [...state.tickets];
}, },
replaceUsers(state, users) { replaceUsers(state, users) {
@ -198,6 +214,10 @@ const store = createStore({
setThumbnail(state, {url, data}) { setThumbnail(state, {url, data}) {
state.thumbnailCache[url] = data; state.thumbnailCache[url] = data;
}, },
setShippingVouchers(state, codes) {
state.shippingVouchers = codes;
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
},
}, },
actions: { actions: {
async login({commit}, {username, password, remember}) { async login({commit}, {username, password, remember}) {
@ -412,6 +432,33 @@ const store = createStore({
async updateTicketPartial({commit, state}, {id, ...ticket}) { async updateTicketPartial({commit, state}, {id, ...ticket}) {
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token); const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
commit('updateTicket', data); commit('updateTicket', data);
},
async fetchShippingVouchers({commit, state}) {
if (!state.user.token) return;
if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/shipping_vouchers/', state.user.token);
if (data && success) {
commit('setShippingVouchers', data);
}
},
async createShippingVoucher({dispatch, state}, code) {
const {data, success} = await http.post('/2/shipping_vouchers/', code, state.user.token);
if (data && success) {
state.fetchedData.shippingVouchers = 0;
dispatch('fetchShippingVouchers');
}
},
async claimShippingVoucher({dispatch, state}, {ticket, shipping_voucher_type}) {
const id = state.shippingVouchers.filter(voucher => voucher.type === shipping_voucher_type && voucher.issue_thread === null)[0].id;
const {
data,
success
} = await http.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket}, state.user.token);
if (data && success) {
state.fetchedData.shippingVouchers = 0;
state.fetchedData.tickets = 0;
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]);
}
} }
}, },
plugins: [ plugins: [
@ -427,7 +474,7 @@ const store = createStore({
] ]
}), }),
sharedStatePlugin({ sharedStatePlugin({
debug: true, debug: false,
isLoadedKey: "shared_loaded", isLoadedKey: "shared_loaded",
clearingMutation: "logout", clearingMutation: "logout",
afterInit: "afterSharedInit", afterInit: "afterSharedInit",
@ -440,6 +487,7 @@ const store = createStore({
"groups", "groups",
"loadedBoxes", "loadedBoxes",
"loadedItems", "loadedItems",
"shippingVouchers",
], ],
watch: [ watch: [
"test", "test",
@ -450,6 +498,7 @@ const store = createStore({
"groups", "groups",
"loadedBoxes", "loadedBoxes",
"loadedItems", "loadedItems",
"shippingVouchers",
], ],
mutations: [ mutations: [
//"replaceTickets", //"replaceTickets",

View file

@ -13,10 +13,6 @@
<font-awesome-icon icon="trash"/> <font-awesome-icon icon="trash"/>
Delete Delete
</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.assigned_to"> <select class="form-control" v-model="ticket.assigned_to">
<option v-for="user in users" :value="user.username">{{ user.username }}</option> <option v-for="user in users" :value="user.username">{{ user.username }}</option>
@ -34,6 +30,24 @@
</button> </button>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between">
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
<font-awesome-icon icon="clipboard"/>
Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard
</ClipboardButton>
<div class="btn-group">
<select class="form-control" v-model="shipping_voucher_type">
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
:value="type.id">{{ type.name }}
</option>
</select>
<button class="form-control btn btn-success"
@click="claimShippingVoucher({ticket: ticket.id, shipping_voucher_type}).then(()=>shipping_voucher_type=null)"
:disabled="!shipping_voucher_type">
Claim&nbsp;Shipping&nbsp;Voucher
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -41,15 +55,21 @@
</template> </template>
<script> <script>
import {mapActions, mapState} from 'vuex'; import {mapActions, mapGetters, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue"; import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue"; import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
export default { export default {
name: 'Ticket', name: 'Ticket',
components: {ClipboardButton, Timeline}, components: {ClipboardButton, Timeline},
data() {
return {
shipping_voucher_type: null
}
},
computed: { computed: {
...mapState(['tickets', 'state_options', 'users']), ...mapState(['tickets', 'state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes']),
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);
@ -63,6 +83,7 @@ export default {
methods: { methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']), ...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']), ...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher']),
handleMail(mail) { handleMail(mail) {
this.sendMail({ this.sendMail({
id: this.ticket.id, id: this.ticket.id,

View file

@ -11,6 +11,9 @@
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link> <router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link>
</li> </li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'shipping'}" active-class="active">Shipping</router-link>
</li>
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" :to="{name: 'admin_boxes'}" active-class="active">Boxes</router-link> <router-link class="nav-link" :to="{name: 'admin_boxes'}" active-class="active">Boxes</router-link>
</li> </li>

View file

@ -0,0 +1,99 @@
<template>
<div>
<h3>Shipping Vouchers</h3>
<div class="mt-3">
<h5>Shipping Voucher Types</h5>
<span v-for="(type, key) in availableShippingVoucherTypes" :key="key" class="mr-2">
<span v-if="type.count > 2" class="badge badge-success">{{ type.name }} - {{ type.count }}</span>
<span v-else-if="type.count > 0" class="badge badge-warning" v-if="type.count > 0">
{{ type.name }} - {{ type.count }}
</span>
<span v-else class="badge badge-danger">{{ type.name }}</span>
</span>
</div>
<div class="mt-3">
<h5>Available Shipping Vouchers</h5>
<ul>
<li v-for="voucher in shippingVouchers" :key="voucher.voucher">
<span v-if="voucher.issue_thread == null">{{ voucher.type }} - {{ voucher.voucher }}</span>
<span v-else><s style="color:var(--danger)">{{ voucher.type }} - {{ voucher.voucher }}</s> used in
<router-link :to="'/'+ getEventSlug + '/ticket/' + voucher.issue_thread">#{{
voucher.issue_thread
}}</router-link></span>
</li>
</ul>
</div>
<div class="mt-3">
<textarea class="form-control mb-3" rows="5" placeholder="Shipping Voucher List" v-model="bulk_vouchers"
v-if="bulk"></textarea>
<div class="input-group">
<input type="text" class="form-control" placeholder="Shipping Voucher" v-model="voucher" v-if="!bulk">
<select class="form-control" v-model="type">
<option v-for="it in Object.keys(shippingVoucherTypes)" :value="it">{{
shippingVoucherTypes[it]
}}
</option>
</select>
<div class="input-group-prepend">
<div class="input-group-text">
<input type="checkbox" v-model="bulk" class="mr-2" id="bulk" style="margin: 0;">
<label for="bulk" style="margin: 0;">Bulk</label>
</div>
</div>
<button class="btn btn-primary form-control" @click="createSingleOrBulkShippingVoucher">
<font-awesome-icon icon="plus"/>
{{ (bulk ? "Add Shipping Vouchers" : "Add Shipping Voucher") }}
</button>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapGetters, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Shipping',
components: {Table},
data() {
return {
voucher: '',
bulk_vouchers: '',
type: '2kg-eu',
bulk: false,
};
},
computed: {
...mapState(['shippingVouchers', 'shippingVoucherTypes']),
...mapGetters(['getEventSlug', 'availableShippingVoucherTypes']),
},
methods: {
...mapActions(['fetchShippingVouchers', 'createShippingVoucher']),
createSingleOrBulkShippingVoucher() {
if (this.bulk) {
const list = this.bulk_vouchers.split('\n');
if (confirm('Are you sure you want to add ' + list.length + ' shipping vouchers as ' + this.type + '?')) {
const jobs = list.map(voucher => {
return this.createShippingVoucher({voucher: voucher.trim(), type: this.type});
});
Promise.all(jobs).then(() => {
this.bulk_vouchers = '';
});
}
} else {
this.createShippingVoucher({voucher: this.voucher, type: this.type}).then(() => {
this.voucher = '';
});
}
},
},
mounted() {
this.fetchShippingVouchers();
},
};
</script>
<style scoped>
</style>