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 tickets.models import IssueThread, Comment, StateChange
from tickets.models import IssueThread, Comment, StateChange, Assignment, ShippingVoucher
class IssueThreadAdmin(admin.ModelAdmin):
@ -15,6 +15,16 @@ class StateChangeAdmin(admin.ModelAdmin):
pass
class AssignmentAdmin(admin.ModelAdmin):
pass
class ShippingVouchersAdmin(admin.ModelAdmin):
pass
admin.site.register(IssueThread, IssueThreadAdmin)
admin.site.register(Comment, CommentAdmin)
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.protocol import send_smtp, make_reply, collect_references
from notify_sessions.models import SystemEvent
from tickets.models import IssueThread, Comment, STATE_CHOICES
from tickets.serializers import IssueSerializer, CommentSerializer
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer
class IssueViewSet(viewsets.ModelViewSet):
@ -22,6 +22,11 @@ class IssueViewSet(viewsets.ModelViewSet):
queryset = IssueThread.objects.all()
class ShippingVoucherViewSet(viewsets.ModelViewSet):
serializer_class = ShippingVoucherSerializer
queryset = ShippingVoucher.objects.all()
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread', raise_exception=True)
@ -113,6 +118,7 @@ def add_comment(request, pk):
router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
urlpatterns = ([
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.utils import timezone
from django_softdelete.models import SoftDeleteModel
from authentication.models import ExtendedUser
@ -116,3 +117,20 @@ class Assignment(models.Model):
def __str__(self):
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 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):
@ -28,6 +28,13 @@ class StateSerializer(serializers.Serializer):
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):
timeline = serializers.SerializerMethodField()
last_activity = serializers.SerializerMethodField()
@ -60,7 +67,10 @@ class IssueSerializer(serializers.ModelSerializer):
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_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)
except AttributeError:
return None
@ -100,6 +110,14 @@ class IssueSerializer(serializers.ModelSerializer):
'timestamp': assignment.timestamp,
'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'])
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 AccessControl from "@/views/admin/AccessControl.vue";
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
import Shipping from "@/views/admin/Shipping.vue";
const routes = [
{path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}},
@ -69,6 +70,10 @@ const routes = [
path: 'boxes/', name: 'admin_boxes', component: BoxesAdmin, meta:
{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}},

View file

@ -6,7 +6,6 @@ import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils";
import sharedStatePlugin from "@/shared-state-plugin";
import persistentStatePlugin from "@/persistent-state-plugin";
import {triggerRef} from "vue";
const store = createStore({
state: {
@ -20,6 +19,8 @@ const store = createStore({
users: [],
groups: [],
state_options: [],
shippingVouchers: [],
lastEvent: '37C3',
lastUsed: {},
remember: false,
@ -40,12 +41,22 @@ const store = createStore({
users: 0,
groups: 0,
states: 0,
shippingVouchers: 0,
},
persistent_loaded: false,
shared_loaded: false,
afterInitHandlers: [],
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: {
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) => {
if (router.currentRoute.value.query.layout)
return router.currentRoute.value.query.layout;
@ -139,7 +156,6 @@ const store = createStore({
updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket);
//triggerRef(state.tickets);
state.tickets = [...state.tickets];
},
replaceUsers(state, users) {
@ -198,6 +214,10 @@ const store = createStore({
setThumbnail(state, {url, data}) {
state.thumbnailCache[url] = data;
},
setShippingVouchers(state, codes) {
state.shippingVouchers = codes;
state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()};
},
},
actions: {
async login({commit}, {username, password, remember}) {
@ -412,6 +432,33 @@ const store = createStore({
async updateTicketPartial({commit, state}, {id, ...ticket}) {
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
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: [
@ -427,7 +474,7 @@ const store = createStore({
]
}),
sharedStatePlugin({
debug: true,
debug: false,
isLoadedKey: "shared_loaded",
clearingMutation: "logout",
afterInit: "afterSharedInit",
@ -440,6 +487,7 @@ const store = createStore({
"groups",
"loadedBoxes",
"loadedItems",
"shippingVouchers",
],
watch: [
"test",
@ -450,6 +498,7 @@ const store = createStore({
"groups",
"loadedBoxes",
"loadedItems",
"shippingVouchers",
],
mutations: [
//"replaceTickets",

View file

@ -13,10 +13,6 @@
<font-awesome-icon icon="trash"/>
Delete
</button-->
<ClipboardButton :payload="shippingEmail" class="btn btn-primary">
<font-awesome-icon icon="clipboard"/>
Copy DHL contact to clipboard
</ClipboardButton>
<div class="btn-group">
<select class="form-control" v-model="ticket.assigned_to">
<option v-for="user in users" :value="user.username">{{ user.username }}</option>
@ -34,6 +30,24 @@
</button>
</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>
@ -41,15 +55,21 @@
</template>
<script>
import {mapActions, mapState} from 'vuex';
import {mapActions, mapGetters, mapState} from 'vuex';
import Timeline from "@/components/Timeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
export default {
name: 'Ticket',
components: {ClipboardButton, Timeline},
data() {
return {
shipping_voucher_type: null
}
},
computed: {
...mapState(['tickets', 'state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes']),
ticket() {
const id = parseInt(this.$route.params.id)
const ret = this.tickets.find(ticket => ticket.id === id);
@ -63,6 +83,7 @@ export default {
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher']),
handleMail(mail) {
this.sendMail({
id: this.ticket.id,

View file

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