Compare commits

...

5 commits

23 changed files with 774 additions and 145 deletions

View file

@ -1,8 +1,6 @@
from django.conf import settings
from django.db import migrations
from authentication.models import ExtendedUser
class Migration(migrations.Migration):
dependencies = [
@ -11,6 +9,7 @@ class Migration(migrations.Migration):
]
def create_legacy_user(apps, schema_editor):
ExtendedUser = apps.get_model('authentication', 'ExtendedUser')
ExtendedUser.objects.create_user(settings.LEGACY_USER_NAME, 'mail@' + settings.MAIL_DOMAIN,
settings.LEGACY_USER_PASSWORD)

View file

@ -6,13 +6,7 @@ from rest_framework.response import Response
from files.models import File
from inventory.models import Event, Container, Item
class EventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
read_only_fields = ['eid']
from inventory.serializers import EventSerializer, ContainerSerializer
class EventViewSet(viewsets.ModelViewSet):
@ -22,18 +16,6 @@ class EventViewSet(viewsets.ModelViewSet):
authentication_classes = []
class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField()
class Meta:
model = Container
fields = ['cid', 'name', 'itemCount']
read_only_fields = ['cid', 'itemCount']
def get_itemCount(self, instance):
return Item.objects.filter(container=instance.cid).count()
class ContainerViewSet(viewsets.ModelViewSet):
serializer_class = ContainerSerializer
queryset = Container.objects.all()

View file

@ -1,21 +1,12 @@
from datetime import datetime
from django.urls import path, re_path
from django.urls import path
from django.contrib.auth.decorators import permission_required
from rest_framework import routers, viewsets, serializers
from rest_framework import routers, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from files.models import File
from inventory.models import Event, Container, Item
class EventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
read_only_fields = ['eid']
from inventory.serializers import EventSerializer, ContainerSerializer, ItemSerializer
class EventViewSet(viewsets.ModelViewSet):
@ -24,87 +15,11 @@ class EventViewSet(viewsets.ModelViewSet):
permission_classes = []
class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField()
class Meta:
model = Container
fields = ['cid', 'name', 'itemCount']
read_only_fields = ['cid', 'itemCount']
def get_itemCount(self, instance):
return Item.objects.filter(container=instance.cid).count()
class ContainerViewSet(viewsets.ModelViewSet):
serializer_class = ContainerSerializer
queryset = Container.objects.all()
class ItemSerializer(serializers.ModelSerializer):
dataImage = serializers.CharField(write_only=True, required=False)
cid = serializers.SerializerMethodField()
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
returned = serializers.SerializerMethodField(required=False)
class Meta:
model = Item
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage', 'returned']
read_only_fields = ['uid']
def get_cid(self, instance):
return instance.container.cid
def get_box(self, instance):
return instance.container.name
def get_file(self, instance):
if len(instance.files.all()) > 0:
return instance.files.all().order_by('-created_at')[0].hash
return None
def get_returned(self, instance):
return instance.returned_at is not None
def to_internal_value(self, data):
container = None
returned = False
if 'cid' in data:
container = Container.objects.get(cid=data['cid'])
if 'returned' in data:
returned = data['returned']
internal = super().to_internal_value(data)
if container:
internal['container'] = container
if returned:
internal['returned_at'] = datetime.now()
return internal
def validate(self, attrs):
return super().validate(attrs)
def create(self, validated_data):
if 'dataImage' in validated_data:
file = File.objects.create(data=validated_data['dataImage'])
validated_data.pop('dataImage')
item = Item.objects.create(**validated_data)
item.files.set([file])
return item
return Item.objects.create(**validated_data)
def update(self, instance, validated_data):
if 'returned' in validated_data:
if validated_data['returned']:
validated_data['returned_at'] = datetime.now()
validated_data.pop('returned')
if 'dataImage' in validated_data:
file = File.objects.create(data=validated_data['dataImage'])
validated_data.pop('dataImage')
instance.files.add(file)
return super().update(instance, validated_data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@permission_required('view_item', raise_exception=True)

View file

@ -0,0 +1,88 @@
from django.utils import timezone
from rest_framework import serializers
from files.models import File
from inventory.models import Event, Container, Item
class EventSerializer(serializers.ModelSerializer):
class Meta:
model = Event
fields = ['eid', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end']
read_only_fields = ['eid']
class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField()
class Meta:
model = Container
fields = ['cid', 'name', 'itemCount']
read_only_fields = ['cid', 'itemCount']
def get_itemCount(self, instance):
return Item.objects.filter(container=instance.cid).count()
class ItemSerializer(serializers.ModelSerializer):
dataImage = serializers.CharField(write_only=True, required=False)
cid = serializers.SerializerMethodField()
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
returned = serializers.SerializerMethodField(required=False)
class Meta:
model = Item
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage', 'returned']
read_only_fields = ['uid']
def get_cid(self, instance):
return instance.container.cid
def get_box(self, instance):
return instance.container.name
def get_file(self, instance):
if len(instance.files.all()) > 0:
return instance.files.all().order_by('-created_at')[0].hash
return None
def get_returned(self, instance):
return instance.returned_at is not None
def to_internal_value(self, data):
container = None
returned = False
if 'cid' in data:
container = Container.objects.get(cid=data['cid'])
if 'returned' in data:
returned = data['returned']
internal = super().to_internal_value(data)
if container:
internal['container'] = container
if returned:
internal['returned_at'] = timezone.now()
return internal
def validate(self, attrs):
return super().validate(attrs)
def create(self, validated_data):
if 'dataImage' in validated_data:
file = File.objects.create(data=validated_data['dataImage'])
validated_data.pop('dataImage')
item = Item.objects.create(**validated_data)
item.files.set([file])
return item
return Item.objects.create(**validated_data)
def update(self, instance, validated_data):
if 'returned' in validated_data:
if validated_data['returned']:
validated_data['returned_at'] = timezone.now()
validated_data.pop('returned')
if 'dataImage' in validated_data:
file = File.objects.create(data=validated_data['dataImage'])
validated_data.pop('dataImage')
instance.files.add(file)
return super().update(instance, validated_data)

View file

@ -3,7 +3,6 @@
from django.db import migrations, models
import django.db.models.deletion
import files.models
from mail.models import Email
from mail.protocol import parse_email_body
@ -24,6 +23,7 @@ class Migration(migrations.Migration):
]
def generate_email_attachments(apps, schema_editor):
Email = apps.get_model('mail', 'Email')
for email in Email.objects.all():
raw = email.raw
if raw is None or raw == '':

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,9 +22,9 @@ class IssueViewSet(viewsets.ModelViewSet):
queryset = IssueThread.objects.all()
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
queryset = Comment.objects.all()
class ShippingVoucherViewSet(viewsets.ModelViewSet):
serializer_class = ShippingVoucherSerializer
queryset = ShippingVoucher.objects.all()
@api_view(['POST'])
@ -118,7 +118,7 @@ def add_comment(request, pk):
router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues')
router.register(r'comments', CommentViewSet, basename='comments')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
urlpatterns = ([
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),

View file

@ -2,17 +2,15 @@
from django.db import migrations, models
from tickets.models import IssueThread
class Migration(migrations.Migration):
dependencies = [
('tickets', '0005_remove_issuethread_last_activity'),
]
def set_uuid(apps, schema_editor):
import uuid
IssueThread = apps.get_model('tickets', 'IssueThread')
for issue_thread in IssueThread.objects.all():
issue_thread.uuid = str(uuid.uuid4())
issue_thread.save()

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

@ -247,16 +247,6 @@ class IssueApiTest(TestCase):
self.assertEqual(timeline[1]['subject'], 'test issue')
self.assertEqual(timeline[1]['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_post_comment_altenative(self):
issue = IssueThread.objects.create(
name="test issue",

View file

@ -8,12 +8,19 @@
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'comment'">
<font-awesome-icon icon="comment"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'" :class="'bg-' + stateInfo(item.state).color">
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'"
:class="'bg-' + stateInfo(item.state).color">
<font-awesome-icon :icon="stateInfo(item.state).icon"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'assignment'" :class="'bg-secondary'">
<font-awesome-icon icon="user"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'item_relation'">
<font-awesome-icon icon="object-group"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'shipping_voucher'">
<font-awesome-icon icon="truck"/>
</span>
<span class="timeline-item-icon faded-icon" v-else>
<font-awesome-icon icon="pen"/>
</span>
@ -21,6 +28,8 @@
<TimelineComment v-else-if="item.type === 'comment'" :item="item"/>
<TimelineStateChange v-else-if="item.type === 'state'" :item="item"/>
<TimelineAssignment v-else-if="item.type === 'assignment'" :item="item"/>
<TimelineRelatedItem v-else-if="item.type === 'item_relation'" :item="item"/>
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
<p v-else>{{ item }}</p>
</li>
<li class="timeline-item">
@ -66,10 +75,15 @@ import TimelineComment from "@/components/TimelineComment.vue";
import TimelineStateChange from "@/components/TimelineStateChange.vue";
import {mapGetters} from "vuex";
import TimelineAssignment from "@/components/TimelineAssignment.vue";
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
export default {
name: 'Timeline',
components: {TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail},
components: {
TimelineShippingVoucher,
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail
},
props: {
timeline: {
type: Array,

View file

@ -0,0 +1,252 @@
<template>
<div class="timeline-item-wrapper">
<Lightbox v-if="lightboxHash" :hash="lightboxHash" @close="closeLightboxModal()"/>
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span><!--a href="#">$USER</a--> linked item <span class="badge badge-secondary">#{{ item.item.uid }} </span> on <time
:datetime="timestamp">{{ timestamp }}</time> as <span class="badge badge-primary">{{ item.status }}</span>
</span>
</div>
<div class="card bg-dark">
<div class="row">
<div class="col" style="min-width: 4em;">
<AuthenticatedImage v-if="item.item.file" cached
:src="`/media/2/256/${item.item.file}/`"
class="d-block w-100 card-img-left"
@click="openLightboxModalWith(item.item)"
/>
</div>
<div class="col">
<div class="card-body">
<h6 class="card-subtitle text-secondary">uid: {{ item.item.uid }} box: {{ item.item.box }}</h6>
<h6 class="card-title">{{ item.item.description }}</h6>
<!--div class="row mx-auto mt-2">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item.item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item.item)"
title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItem(item.item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</div>
<p>{{ item }}</p-->
</div>
</div>
</div>
</div>
<!--button class="show-replies">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward"
width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M15 11l4 4l-4 4m4 -4h-11a4 4 0 0 1 0 -8h1"/>
</svg>
Show 3 replies
<span class="avatar-list">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
</span>
</button-->
</div>
</template>
<script>
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AuthenticatedDataLink from "@/components/AuthenticatedDataLink.vue";
import Lightbox from "@/components/Lightbox.vue";
export default {
name: 'TimelineRelatedItem',
components: {Lightbox, AuthenticatedImage, AuthenticatedDataLink},
data() {
return {
lightboxHash: null,
}
},
props: {
'item': {
type: Object,
required: true
}
},
computed: {
'timestamp': function () {
return new Date(this.item.timestamp).toLocaleString();
},
'body': function () {
return this.item.body.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>');
}
},
methods: {
openLightboxModalWith(attachment) {
this.lightboxHash = attachment.hash;
},
closeLightboxModal() { // Closes the editing modal and discards the edited copy of the item.
this.lightboxHash = null;
},
},
};
</script>
<style scoped>
a {
color: inherit;
}
.card-img-left {
border-top-left-radius: calc(.25rem - 1px);
border-bottom-left-radius: calc(.25rem - 1px);
}
/*img {
display: block;
max-width: 100%;
}*/
.timeline-item-description {
display: flex;
padding-top: 6px;
gap: 8px;
color: var(--gray);
img {
flex-shrink: 0;
}
a {
/*color: var(--c-grey-500);*/
font-weight: 500;
text-decoration: none;
&:hover,
&:focus {
outline: 0; /* Don't actually do this */
color: var(--info);
}
}
}
.card {
border: 1px solid var(--gray);
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
aspect-ratio: 1 / 1;
flex-shrink: 0;
width: 40px;
height: 40px;
&.small {
width: 28px;
height: 28px;
}
img {
object-fit: cover;
}
}
.button {
border: 0;
display: inline-flex;
vertical-align: middle;
margin-right: 4px;
margin-top: 12px;
align-items: center;
justify-content: center;
font-size: 1rem;
height: 32px;
padding: 0 8px;
background-color: var(--gray);
flex-shrink: 0;
cursor: pointer;
border-radius: 99em;
&:hover {
background-color: var(--info);
}
&.square {
border-radius: 50%;
color: var(--gray);
background-color: var(--dark);
width: 32px;
height: 32px;
padding: 0;
svg {
width: 24px;
height: 24px;
}
&:hover {
color: var(--info);
}
}
}
.show-replies {
color: var(--gray);
background-color: transparent;
border: 0;
padding: 0;
margin-top: 16px;
display: flex;
align-items: center;
gap: 6px;
font-size: 1rem;
cursor: pointer;
svg {
flex-shrink: 0;
width: 24px;
height: 24px;
}
&:hover,
&:focus {
color: var(--info);
}
}
.avatar-list {
display: flex;
align-items: center;
& > * {
position: relative;
box-shadow: 0 0 0 2px #fff;
background: var(--dark);
margin-right: -8px;
}
}
</style>

View file

@ -0,0 +1,92 @@
<template>
<div class="timeline-item-description">
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<span><a href="#">$USER</a> has claimed shipping voucher
<ClipboardButton class="btn btn-primary badge badge-pill" title="Copy shipping voucher to clipboard"
:payload="item.voucher">{{ item.voucher }}
<font-awesome-icon icon="clipboard"/>
</ClipboardButton> of type <span class="badge badge-pill badge-secondary">{{ item.voucher_type }}</span> for this ticket at <time
:datetime="timestamp">{{ timestamp }}</time>
</span>
</div>
</template>
<script>
import {mapState} from "vuex";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
export default {
name: 'TimelineShippingVoucher',
components: {ClipboardButton},
props: {
'item': {
type: Object,
required: true
}
},
computed: {
...mapState(['state_options']),
'timestamp': function () {
return new Date(this.item.timestamp).toLocaleString();
},
}
};
</script>
<style scoped>
a {
color: inherit;
}
.timeline-item-description {
display: flex;
padding-top: 6px;
gap: 8px;
color: var(--gray);
img {
flex-shrink: 0;
}
a {
/*color: var(--c-grey-500);*/
font-weight: 500;
text-decoration: none;
&:hover,
&:focus {
outline: 0; /* Don't actually do this */
color: var(--info);
}
}
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
aspect-ratio: 1 / 1;
flex-shrink: 0;
width: 40px;
height: 40px;
&.small {
width: 28px;
height: 28px;
}
img {
object-fit: cover;
}
}
</style>

View file

@ -25,7 +25,15 @@ export default {
computed: {
...mapState(['state_options']),
lookupState: function () {
try {
if (this.item.state)
return this.state_options.find(state => state.value === this.item.state);
} catch (e) {
}
return {
text: 'Unknown',
value: 'unknown'
};
},
colorLookup: function () {
if (this.item.state.startsWith('closed_')) {

View file

@ -39,13 +39,15 @@ import {
faClipboard,
faTasks,
faAngleRight,
faAngleDown
faAngleDown,
faTruck,
faObjectGroup
} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
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,
faArchive, faMinus, faExclamation, faHourglass, faClipboard, faTasks, faAngleDown, faAngleRight);
faArchive, faMinus, faExclamation, faHourglass, faClipboard, faTasks, faAngleDown, faAngleRight, faTruck, faObjectGroup);
const app = createApp(App).use(store).use(router);

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>