Compare commits

...

4 commits

Author SHA1 Message Date
2fce260ba8 test on every push
All checks were successful
/ test (push) Successful in 50s
/ deploy (push) Successful in 4m41s
2024-11-18 02:17:30 +01:00
c9d58191b3 show tickets filtered by active event 2024-11-18 02:17:30 +01:00
41b71bd51a partition tickets by event 2024-11-18 02:17:30 +01:00
d73bebd5de match incoming mail to event 2024-11-18 02:16:55 +01:00
19 changed files with 272 additions and 236 deletions

View file

@ -1,5 +1,6 @@
on:
pull_request:
push:
jobs:
test:

View file

@ -15,9 +15,11 @@ import sys
import dotenv
from pathlib import Path
def truthy_str(s):
return s.lower() in ['true', '1', 't', 'y', 'yes', 'yeah', 'yup', 'certainly', 'sure', 'positive', 'uh-huh', '👍']
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

View file

@ -1,16 +0,0 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

View file

@ -1,4 +1,4 @@
from django.urls import path
from django.urls import re_path
from django.contrib.auth.decorators import permission_required
from rest_framework import routers, viewsets
from rest_framework.decorators import api_view, permission_classes
@ -40,7 +40,9 @@ def search_items(request, event_slug, query):
@permission_classes([IsAuthenticated])
def item(request, event_slug):
try:
event = Event.objects.get(slug=event_slug)
event = None
if event_slug != 'none':
event = Event.objects.get(slug=event_slug)
if request.method == 'GET':
if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403)
@ -99,8 +101,8 @@ router.register(r'boxes', ContainerViewSet, basename='boxes')
router.register(r'box', ContainerViewSet, basename='boxes')
urlpatterns = router.urls + [
path('<event_slug>/items/', item),
path('<event_slug>/items/<query>/', search_items),
path('<event_slug>/item/', item),
path('<event_slug>/item/<id>/', item_by_id),
re_path(r'^(?P<event_slug>[\w-]+)/items/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/items/(?P<query>[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'),
re_path(r'^(?P<event_slug>[\w-]+)/item/$', item, name='item'),
re_path(r'^(?P<event_slug>[\w-]+)/item/(?P<id>\d+)/$', item_by_id, name='item_by_id'),
]

View file

@ -39,10 +39,12 @@ class ItemSerializer(serializers.ModelSerializer):
box = serializers.SerializerMethodField()
file = serializers.SerializerMethodField()
returned = serializers.SerializerMethodField(required=False)
event = serializers.SlugRelatedField(slug_field='slug', queryset=Event.objects.all(),
allow_null=True, required=False)
class Meta:
model = Item
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage', 'returned']
fields = ['cid', 'box', 'uid', 'description', 'file', 'dataImage', 'returned', 'event']
read_only_fields = ['uid']
def get_cid(self, instance):

View file

@ -30,7 +30,7 @@ class ItemTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
[{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': None,
'returned': False}])
'returned': False, 'event': self.event.slug}])
def test_members_with_file(self):
import base64
@ -40,7 +40,7 @@ class ItemTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
[{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': file.hash,
'returned': False}])
'returned': False, 'event': self.event.slug}])
def test_multi_members(self):
Item.objects.create(container=self.box, event=self.event, description='1')
@ -55,7 +55,7 @@ class ItemTestCase(TestCase):
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json(),
{'uid': 1, 'description': '1', 'box': 'BOX', 'cid': self.box.cid, 'file': None,
'returned': False})
'returned': False, 'event': self.event.slug})
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '1')
@ -86,7 +86,7 @@ class ItemTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(),
{'uid': 1, 'description': '2', 'box': 'BOX', 'cid': self.box.cid, 'file': None,
'returned': False})
'returned': False, 'event': self.event.slug})
self.assertEqual(len(Item.objects.all()), 1)
self.assertEqual(Item.objects.all()[0].uid, 1)
self.assertEqual(Item.objects.all()[0].description, '2')

View file

@ -29,13 +29,13 @@ class Email(SoftDeleteModel):
self.save()
def train_spam(self):
if ACTIVE_SPAM_TRAINING:
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_spam", path])
def train_ham(self):
if ACTIVE_SPAM_TRAINING:
if ACTIVE_SPAM_TRAINING and self.raw_file.path:
import subprocess
path = self.raw_file.path
subprocess.run(["rspamc", "learn_ham", path])

View file

@ -91,7 +91,7 @@ async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
def find_active_issue_thread(in_reply_to, address, subject):
def find_active_issue_thread(in_reply_to, address, subject, event):
from re import match
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
if uuid_match:
@ -102,7 +102,7 @@ def find_active_issue_thread(in_reply_to, address, subject):
if reply_to.exists():
return reply_to.first().issue_thread, False
else:
issue = IssueThread.objects.create(name=subject)
issue = IssueThread.objects.create(name=subject, event=event)
return issue, True
@ -202,11 +202,14 @@ def receive_email(envelope, log=None):
subject = unescape_and_decode_base64(subject)
target_event = find_target_event(recipient)
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject)
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
from hashlib import sha256
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
email = Email.objects.create(
sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content), event=target_event,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename), event=target_event,
issue_thread=active_issue_thread)
for attachment in attachments:
email.attachments.add(attachment)

View file

@ -142,6 +142,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test ä', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_quoted_printable_2(self):
from aiosmtpd.smtp import Envelope
@ -162,6 +163,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('suche_Mütze', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_base64(self):
from aiosmtpd.smtp import Envelope
@ -182,6 +184,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_client_reply(self):
issue_thread = IssueThread.objects.create(
@ -229,6 +232,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_new')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_handle_client_reply_2(self):
issue_thread = IssueThread.objects.create(
@ -281,6 +285,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_open')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_mail_reply(self):
issue_thread = IssueThread.objects.create(
@ -384,6 +389,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)
self.assertEqual(event, IssueThread.objects.all()[0].event)
def test_mail_html_body(self):
from aiosmtpd.smtp import Envelope

View file

@ -56,7 +56,7 @@ def reply(request, pk):
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread_manual', raise_exception=True)
def manual_ticket(request):
def manual_ticket(request, event_slug):
if 'name' not in request.data:
return Response({'status': 'error', 'message': 'missing name'}, status=status.HTTP_400_BAD_REQUEST)
if 'sender' not in request.data:
@ -66,8 +66,16 @@ def manual_ticket(request):
if 'body' not in request.data:
return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST)
event = None
if event_slug != 'none':
try:
event = Event.objects.get(slug=event_slug)
except:
return Response({'status': 'error', 'message': 'invalid event'}, status=status.HTTP_400_BAD_REQUEST)
issue = IssueThread.objects.create(
name=request.data['name'],
event=event,
manually_created=True,
)
email = Email.objects.create(
@ -122,8 +130,8 @@ router.register(r'tickets', IssueViewSet, basename='issues')
router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers')
urlpatterns = ([
re_path(r'tickets/states/$', get_available_states, name='get_available_states'),
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/states/$', get_available_states, name='get_available_states'),
re_path(r'^(?P<event_slug>[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'),
] + router.urls)

View file

@ -1,6 +1,7 @@
from rest_framework import serializers
from authentication.models import ExtendedUser
from inventory.models import Event
from mail.api_v2 import AttachmentSerializer
from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher
from inventory.serializers import ItemSerializer
@ -41,19 +42,19 @@ class IssueSerializer(serializers.ModelSerializer):
last_activity = serializers.SerializerMethodField()
assigned_to = serializers.SlugRelatedField(slug_field='username', queryset=ExtendedUser.objects.all(),
allow_null=True, required=False)
event = serializers.SlugRelatedField(slug_field='slug', queryset=Event.objects.all(),
allow_null=True, required=False)
related_items = ItemSerializer(many=True, read_only=True)
class Meta:
model = IssueThread
fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items')
fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event')
read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items')
def to_internal_value(self, data):
ret = super().to_internal_value(data)
if 'state' in data:
ret['state'] = data['state']
# if 'assigned_to' in data:
# ret['assigned_to'] = data['assigned_to']
return ret
def validate(self, attrs):

View file

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from django.test import TestCase, Client
from authentication.models import ExtendedUser
from inventory.models import Event
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment
from django.contrib.auth.models import Permission
@ -16,6 +17,7 @@ class IssueApiTest(TestCase):
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.event = Event.objects.create(slug='evt')
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
@ -28,6 +30,7 @@ class IssueApiTest(TestCase):
now = datetime.now()
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
@ -61,6 +64,7 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['id'], issue.id)
self.assertEqual(response.json()[0]['name'], "test issue")
self.assertEqual(response.json()[0]['state'], "pending_new")
self.assertEqual(response.json()[0]['event'], "evt")
self.assertEqual(response.json()[0]['assigned_to'], None)
self.assertEqual(response.json()[0]['uuid'], issue.uuid)
self.assertEqual(response.json()[0]['last_activity'], comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
@ -93,12 +97,15 @@ class IssueApiTest(TestCase):
now = datetime.now()
issue1 = IssueThread.objects.create(
name="test issue",
event=self.event,
)
issue2 = IssueThread.objects.create(
name="test issue",
event=self.event,
)
issue3 = IssueThread.objects.create(
name="test issue",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
@ -118,8 +125,11 @@ class IssueApiTest(TestCase):
self.assertEqual(200, response.status_code)
self.assertEqual(3, len(response.json()))
self.assertEqual(issue1.id, response.json()[0]['id'])
self.assertEqual("evt", response.json()[0]['event'])
self.assertEqual(issue2.id, response.json()[1]['id'])
self.assertEqual("evt", response.json()[1]['event'])
self.assertEqual(issue3.id, response.json()[2]['id'])
self.assertEqual("evt", response.json()[2]['event'])
self.assertEqual(issue1.state_changes.first().timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
response.json()[0]['last_activity'])
self.assertEqual(mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
@ -153,6 +163,7 @@ class IssueApiTest(TestCase):
now = datetime.now()
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
@ -189,6 +200,7 @@ class IssueApiTest(TestCase):
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
self.assertEqual("evt", response.json()[0]['event'])
self.assertEqual('pending_new', response.json()[0]['state'])
self.assertEqual('test issue', response.json()[0]['name'])
self.assertEqual(None, response.json()[0]['assigned_to'])
@ -230,13 +242,14 @@ class IssueApiTest(TestCase):
self.assertEqual(file2.hash, response.json()[0]['timeline'][1]['attachments'][1]['hash'])
def test_manual_creation(self):
response = self.client.post('/api/2/tickets/manual/',
response = self.client.post('/api/2/evt/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['state'], 'pending_new')
self.assertEqual(response.json()['name'], 'test issue')
self.assertEqual(response.json()['assigned_to'], None)
self.assertEqual("evt", response.json()['event'])
timeline = response.json()['timeline']
self.assertEqual(len(timeline), 2)
self.assertEqual(timeline[0]['type'], 'state')
@ -247,9 +260,35 @@ class IssueApiTest(TestCase):
self.assertEqual(timeline[1]['subject'], 'test issue')
self.assertEqual(timeline[1]['body'], 'test')
def test_manual_creation_none(self):
response = self.client.post('/api/2/none/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['state'], 'pending_new')
self.assertEqual(response.json()['name'], 'test issue')
self.assertEqual(response.json()['assigned_to'], None)
self.assertEqual(None, response.json()['event'])
timeline = response.json()['timeline']
self.assertEqual(len(timeline), 2)
self.assertEqual(timeline[0]['type'], 'state')
self.assertEqual(timeline[0]['state'], 'pending_new')
self.assertEqual(timeline[1]['type'], 'mail')
self.assertEqual(timeline[1]['sender'], 'test')
self.assertEqual(timeline[1]['recipient'], 'test')
self.assertEqual(timeline[1]['subject'], 'test issue')
self.assertEqual(timeline[1]['body'], 'test')
def test_manual_creation_invalid(self):
response = self.client.post('/api/2/foobar/tickets/manual/',
{'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'},
content_type='application/json')
self.assertEqual(response.status_code, 400)
def test_post_comment_altenative(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': 'test'})
self.assertEqual(response.status_code, 201)
@ -260,6 +299,7 @@ class IssueApiTest(TestCase):
def test_post_alt_comment_empty(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': ''})
self.assertEqual(response.status_code, 400)
@ -267,6 +307,7 @@ class IssueApiTest(TestCase):
def test_state_change(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'pending_open'},
content_type='application/json')
@ -284,6 +325,7 @@ class IssueApiTest(TestCase):
def test_state_change_invalid_state(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'},
content_type='application/json')
@ -292,12 +334,14 @@ class IssueApiTest(TestCase):
def test_assign_user(self):
issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'assigned_to': self.user.username},
content_type='application/json')
self.assertEqual(200, response.status_code)
self.assertEqual('pending_new', response.json()['state'])
self.assertEqual('test issue', response.json()['name'])
self.assertEqual("evt", response.json()['event'])
self.assertEqual(self.user.username, response.json()['assigned_to'])
timeline = response.json()['timeline']
self.assertEqual(2, len(timeline))

View file

@ -6,7 +6,8 @@
{{ getEventSlug }}
</button>
<div class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton">
<a class="dropdown-item text-light" href="#" v-for="(event, index) in events" v-bind:key="index"
<a class="dropdown-item text-light" href="#" v-for="(event, index) in selectableEvents"
v-bind:key="index"
:class="{ active: event.slug === getEventSlug }" @click="changeEvent(event)">{{ event.slug }}</a>
</div>
</div>
@ -48,12 +49,12 @@
</button>
</div>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')"
v-if="isItemView()">
v-if="isItemView() && getEventSlug !== 'all'">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Item</span>
</button>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addTicketClicked')"
v-if="isTicketView()">
v-if="isTicketView() && getEventSlug !== 'all'">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Ticket</span>
</button>
@ -64,19 +65,6 @@
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ml-auto">
<!--li class="nav-item dropdown">
<button class="btn nav-link dropdown-toggle" type="button" id="dropdownMenuButton2"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ getActiveView }}
</button>
<ul class="dropdown-menu bg-dark" aria-labelledby="dropdownMenuButton2">
<li class="" v-for="(link, index) in views" v-bind:key="index"
:class="{ active: link.path === getActiveView }">
<a class="nav-link text-nowrap" href="#" @click="changeView(link)">{{ link.title }}</a>
</li>
</ul>
</li-->
<li class="nav-item" v-for="(link, index) in links" v-bind:key="index">
<a class="nav-link text-nowrap" :href="link.path" @click.prevent="navigateTo(link.path)">
{{ link.title }}
@ -115,6 +103,9 @@ export default {
computed: {
...mapState(['events']),
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions", "layout", "route"]),
selectableEvents() {
return [{slug: 'all'}, ...this.events, {slug: 'none'}];
}
},
methods: {
...mapActions(['changeEvent', 'changeView']),

View file

@ -3,7 +3,7 @@ import router from './router';
import * as base64 from 'base-64';
import * as utf8 from 'utf8';
import {ticketStateColorLookup, ticketStateIconLookup, http} from "@/utils";
import {ticketStateColorLookup, ticketStateIconLookup, http, http_session} from "@/utils";
import sharedStatePlugin from "@/shared-state-plugin";
import persistentStatePlugin from "@/persistent-state-plugin";
@ -11,17 +11,18 @@ const store = createStore({
state: {
keyIncrement: 0,
events: [],
loadedItems: [],
itemCache: {},
items: [],
loadedBoxes: [],
toasts: [],
tickets: [],
users: [],
groups: [],
state_options: [],
shippingVouchers: [],
lastEvent: '37C3',
loadedItems: {},
loadedTickets: {},
lastEvent: 'all',
lastUsed: {},
searchQuery: '',
remember: false,
@ -62,7 +63,14 @@ const store = createStore({
},
getters: {
route: state => router.currentRoute.value,
session: state => http_session(state.user.token),
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
getAllItems: state => Object.values(state.loadedItems).flat(),
getAllTickets: state => Object.values(state.loadedTickets).flat(),
getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug),
isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
getActiveView: state => router.currentRoute.value.name || 'items',
getFilters: state => router.currentRoute.value.query,
getBoxes: state => state.loadedBoxes,
@ -130,35 +138,48 @@ const store = createStore({
changeView(state, {view, slug}) {
router.push({path: `/${slug}/${view}`});
},
replaceLoadedItems(state, newItems) {
state.loadedItems = newItems;
state.fetchedData = {...state.fetchedData, items: Date.now()}; // TODO: manage caching items for different events and search results correctly
},
setItemCache(state, {slug, items}) {
state.itemCache[slug] = items;
},
replaceBoxes(state, loadedBoxes) {
state.loadedBoxes = loadedBoxes;
state.fetchedData = {...state.fetchedData, boxes: Date.now()};
},
setItems(state, {slug, items}) {
state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
},
replaceItems(state, items) {
const groups = Object.groupBy(items, i => i.event ? i.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedItems[key] = value;
state.loadedItems = {...state.loadedItems};
console.log(state.loadedItems)
},
updateItem(state, updatedItem) {
const item = state.loadedItems.filter(({uid}) => uid === updatedItem.uid)[0];
const item = state.loadedItems[updatedItem.event?updatedItem.event:'none'].filter(
({uid}) => uid === updatedItem.uid)[0];
Object.assign(item, updatedItem);
},
removeItem(state, item) {
state.loadedItems = state.loadedItems.filter(it => it !== item);
state.loadedItems[item.event?item.event:'none'] = state.loadedItems[item.event].filter(it => it !== item);
},
appendItem(state, item) {
state.loadedItems.push(item);
state.loadedItems[item.event?item.event:'none'].push(item);
},
setTickets(state, {slug, tickets}) {
state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
},
replaceTickets(state, tickets) {
state.tickets = tickets;
state.fetchedData = {...state.fetchedData, tickets: Date.now()};
const groups = Object.groupBy(tickets, t => t.event ? t.event : 'none')
for (const [key, value] of Object.entries(groups)) state.loadedTickets[key] = value;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
},
updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
const ticket = state.loadedTickets[updatedTicket.event?updatedTicket.event:'none'].filter(
({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket);
state.tickets = [...state.tickets];
state.loadedTickets = {...state.loadedTickets};
},
replaceUsers(state, users) {
state.users = users;
@ -249,7 +270,7 @@ const store = createStore({
return false;
}
},
async reloadToken({commit, state, getters}) {
async reloadToken({commit, state}) {
try {
if (state.user.username && state.user.password) {
const data = await fetch('/api/2/login/', {
@ -297,59 +318,51 @@ const store = createStore({
async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}});
},
async loadUserInfo({commit, state}) {
const {data, success} = await http.get('/2/self/', state.user.token);
async loadUserInfo({commit, getters}) {
const {data, success} = await getters.session.get('/2/self/');
commit('setPermissions', data.permissions);
},
async loadEvents({commit, state}) {
async loadEvents({commit, state, getters}) {
if (!state.user.token) return;
if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/events/', state.user.token);
if (data && success)
commit('replaceEvents', data);
const {data, success} = await getters.session.get('/2/events/');
if (data && success) commit('replaceEvents', data);
},
async createEvent({commit, dispatch, state}, event) {
const {data, success} = await http.post('/2/events/', event, state.user.token);
if (data && success)
commit('replaceEvents', [...state.events, data]);
async createEvent({commit, dispatch, state, getters}, event) {
const {data, success} = await getters.session.post('/2/events/', event);
if (data && success) commit('replaceEvents', [...state.events, data]);
},
async deleteEvent({commit, dispatch, state}, event_id) {
const {data, success} = await http.delete(`/2/events/${event_id}/`, state.user.token);
async deleteEvent({commit, dispatch, state, getters}, event_id) {
const {data, success} = await getters.session.delete(`/2/events/${event_id}/`);
if (success) {
await dispatch('loadEvents')
commit('replaceEvents', [...state.events.filter(e => e.eid !== event_id)])
}
},
async fetchTicketStates({commit, state}) {
async fetchTicketStates({commit, state, getters}) {
if (!state.user.token) return;
if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/tickets/states/', state.user.token);
if (data && success)
commit('replaceTicketStates', data);
const {data, success} = await getters.session.get('/2/tickets/states/');
if (data && success) commit('replaceTicketStates', data);
},
changeEvent({dispatch, getters, commit}, eventName) {
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
dispatch('loadEventItems');
async changeEvent({dispatch, getters, commit}, eventName) {
await router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
//dispatch('loadEventItems');
},
changeView({getters}, link) {
router.push({path: `/${getters.getEventSlug}/${link.path}/`});
async changeView({getters}, link) {
await router.push({path: `/${getters.getEventSlug}/${link.path}/`});
},
showBoxContent({getters}, box) {
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
async showBoxContent({getters}, box) {
await router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
},
async loadEventItems({commit, getters, state}) {
if (!state.user.token) return;
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
try {
commit('replaceLoadedItems', []);
const slug = getters.getEventSlug;
if (slug in state.itemCache) {
commit('replaceLoadedItems', state.itemCache[slug]);
}
const {data, success} = await http.get(`/2/${slug}/items/`, state.user.token);
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
if (data && success) {
commit('replaceLoadedItems', data);
commit('setItemCache', {slug, items: data});
commit('setItems', {slug, items: data});
}
} catch (e) {
console.error("Error loading items");
@ -357,129 +370,124 @@ const store = createStore({
},
async searchEventItems({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const {data, success} = await http.get(`/2/${getters.getEventSlug}/items/${encoded_query}/`, state.user.token);
if (data && success)
commit('replaceLoadedItems', data);
const slug = getters.getEventSlug;
const {
data, success
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
if (data && success) {
commit('setItems', {slug, items: data});
}
},
async loadBoxes({commit, state}) {
async loadBoxes({commit, state, getters}) {
if (!state.user.token) return;
if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/boxes/', state.user.token);
if (data && success)
commit('replaceBoxes', data);
const {data, success} = await getters.session.get('/2/boxes/');
if (data && success) commit('replaceBoxes', data);
},
async createBox({commit, dispatch, state}, box) {
const {data, success} = await http.post('/2/boxes/', box, state.user.token);
async createBox({commit, dispatch, state, getters}, box) {
const {data, success} = await getters.session.post('/2/boxes/', box);
commit('replaceBoxes', data);
dispatch('loadBoxes').then(() => {
commit('closeAddBoxModal');
});
},
async deleteBox({commit, dispatch, state}, box_id) {
await http.delete(`/2/boxes/${box_id}/`, state.user.token);
async deleteBox({commit, dispatch, state, getters}, box_id) {
await getters.session.delete(`/2/boxes/${box_id}/`);
dispatch('loadBoxes');
},
async updateItem({commit, getters, state}, item) {
const {
data,
success
} = await http.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item, state.user.token);
data, success
} = await getters.session.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
commit('updateItem', data);
},
async markItemReturned({commit, getters, state}, item) {
await http.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true}, state.user.token);
await getters.session.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true},
state.user.token);
commit('removeItem', item);
},
async deleteItem({commit, getters, state}, item) {
await http.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item, state.user.token);
await getters.session.delete(`/2/${getters.getEventSlug}/item/${item.uid}/`, item);
commit('removeItem', item);
},
async postItem({commit, getters, state}, item) {
commit('updateLastUsed', {box: item.box, cid: item.cid});
const {data, success} = await http.post(`/2/${getters.getEventSlug}/item/`, item, state.user.token);
const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/`, item);
commit('appendItem', data);
},
async loadTickets({commit, state}) {
async loadTickets({commit, state, getters}) {
if (!state.user.token) return;
if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/tickets/', state.user.token);
if (data && success)
commit('replaceTickets', data);
//if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await getters.session.get('/2/tickets/');
if (data && success) commit('replaceTickets', data);
},
async searchEventTickets({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const {data, success} = await http.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`, state.user.token);
if (data && success)
commit('replaceTickets', data);
const {
data, success
} = await getters.session.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data);
},
async sendMail({commit, dispatch, state}, {id, message}) {
const {data, success} = await http.post(`/2/tickets/${id}/reply/`, {message}, state.user.token);
async sendMail({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},
state.user.token);
if (data && success) {
state.fetchedData.tickets = 0;
await dispatch('loadTickets');
}
},
async postManualTicket({commit, dispatch, state}, {sender, message, title,}) {
const {data, success} = await http.post(`/2/tickets/manual/`, {
name: title,
sender,
body: message,
recipient: 'mail@c3lf.de'
}, state.user.token);
async postManualTicket({commit, dispatch, state, getters}, {sender, message, title,}) {
const {data, success} = await getters.session.post(`/2/tickets/manual/`, {
name: title, sender, body: message, recipient: 'mail@c3lf.de'
});
await dispatch('loadTickets');
},
async postComment({commit, dispatch, state}, {id, message}) {
const {data, success} = await http.post(`/2/tickets/${id}/comment/`, {comment: message}, state.user.token);
async postComment({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/comment/`, {comment: message});
if (data && success) {
state.fetchedData.tickets = 0;
await dispatch('loadTickets');
}
},
async loadUsers({commit, state}) {
async loadUsers({commit, state, getters}) {
if (!state.user.token) return;
if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/users/', state.user.token);
if (data && success)
commit('replaceUsers', data);
const {data, success} = await getters.session.get('/2/users/');
if (data && success) commit('replaceUsers', data);
},
async loadGroups({commit, state}) {
async loadGroups({commit, state, getters}) {
if (!state.user.token) return;
if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return;
const {data, success} = await http.get('/2/groups/', state.user.token);
if (data && success)
commit('replaceGroups', data);
const {data, success} = await getters.session.get('/2/groups/');
if (data && success) commit('replaceGroups', data);
},
async updateTicket({commit, state}, ticket) {
const {data, success} = await http.put(`/2/tickets/${ticket.id}/`, ticket, state.user.token);
async updateTicket({commit, state, getters}, ticket) {
const {data, success} = await getters.session.put(`/2/tickets/${ticket.id}/`, ticket);
commit('updateTicket', data);
},
async updateTicketPartial({commit, state}, {id, ...ticket}) {
const {data, success} = await http.patch(`/2/tickets/${id}/`, ticket, state.user.token);
async updateTicketPartial({commit, state, getters}, {id, ...ticket}) {
const {data, success} = await getters.session.patch(`/2/tickets/${id}/`, ticket);
commit('updateTicket', data);
},
async fetchShippingVouchers({commit, state}) {
async fetchShippingVouchers({commit, state, getters}) {
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);
const {data, success} = await getters.session.get('/2/shipping_vouchers/');
if (data && success) {
commit('setShippingVouchers', data);
}
},
async createShippingVoucher({dispatch, state}, code) {
const {data, success} = await http.post('/2/shipping_vouchers/', code, state.user.token);
async createShippingVoucher({dispatch, state, getters}, code) {
const {data, success} = await getters.session.post('/2/shipping_vouchers/', code);
if (data && success) {
state.fetchedData.shippingVouchers = 0;
dispatch('fetchShippingVouchers');
}
},
async claimShippingVoucher({dispatch, state}, {ticket, shipping_voucher_type}) {
async claimShippingVoucher({dispatch, state, getters}, {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);
const {data, success} = await getters.session.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket});
if (data && success) {
state.fetchedData.shippingVouchers = 0;
state.fetchedData.tickets = 0;
@ -487,58 +495,30 @@ const store = createStore({
}
}
},
plugins: [
persistentStatePlugin({ // TODO change remember to some kind of enable field
prefix: "lf_",
debug: false,
isLoadedKey: "persistent_loaded",
state: [
"remember",
"user",
"events",
"lastUsed",
]
}),
sharedStatePlugin({
debug: false,
isLoadedKey: "shared_loaded",
clearingMutation: "logout",
afterInit: "afterSharedInit",
state: [
"test",
"state_options",
"fetchedData",
"tickets",
"users",
"groups",
"loadedBoxes",
"loadedItems",
"shippingVouchers",
],
watch: [
"test",
"state_options",
"fetchedData",
"tickets",
"users",
"groups",
"loadedBoxes",
"loadedItems",
"shippingVouchers",
],
mutations: [
//"replaceTickets",
],
}),
],
plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field
prefix: "lf_",
debug: false,
isLoadedKey: "persistent_loaded",
state: ["remember", "user", "events", "lastUsed",]
}), sharedStatePlugin({
debug: false,
isLoadedKey: "shared_loaded",
clearingMutation: "logout",
afterInit: "afterSharedInit",
state: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
watch: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",],
mutations: [//"replaceTickets",
],
}),],
});
store.watch((state) => state.user, (user) => {
if (store.getters.isLoggedIn) {
if (router.currentRoute.value.name === 'login' && router.currentRoute.value.query.redirect)
if (router.currentRoute.value.name === 'login' && router.currentRoute.value.query.redirect) {
router.push(router.currentRoute.value.query.redirect);
else if (router.currentRoute.value.name === 'login')
} else if (router.currentRoute.value.name === 'login') {
router.push('/');
}
} else {
if (router.currentRoute.value.name !== 'login') {
router.push({

View file

@ -100,4 +100,12 @@ const http = {
}
}
export {ticketStateColorLookup, ticketStateIconLookup, http};
const http_session = token => ({
get: async (url) => await http.get(url, token),
post: async (url, data) => await http.post(url, data, token),
put: async (url, data) => await http.put(url, data, token),
patch: async (url, data) => await http.patch(url, data, token),
delete: async (url) => await http.delete(url, token),
});
export {ticketStateColorLookup, ticketStateIconLookup, http, http_session};

View file

@ -1,5 +1,5 @@
<template>
<AsyncLoader :loaded="loadedItems.length > 0">
<AsyncLoader :loaded="isItemsLoaded">
<div class="container-fluid px-xl-5 mt-3">
<Modal title="Edit Item" v-if="editingItem" @close="closeEditingModal()">
<template #body>
@ -18,7 +18,7 @@
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['uid', 'description', 'box']"
:items="loadedItems"
:items="getEventItems"
:keyName="'uid'"
@itemActivated="openLightboxModalWith($event)"
>
@ -44,7 +44,7 @@
<Cards
v-if="layout === 'cards'"
:columns="['uid', 'description', 'box']"
:items="loadedItems"
:items="getEventItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="openLightboxModalWith($event)"
@ -97,8 +97,8 @@ export default {
}),
components: {AsyncLoader, AuthenticatedImage, Lightbox, Table, Cards, Modal, EditItem},
computed: {
...mapState(['loadedItems']),
...mapGetters(['layout']),
...mapState([]),
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),

View file

@ -80,11 +80,11 @@ export default {
}
},
computed: {
...mapState(['tickets', 'state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes']),
...mapState(['state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes', 'getAllTickets', 'route']),
ticket() {
const id = parseInt(this.$route.params.id)
const ret = this.tickets.find(ticket => ticket.id === id);
const id = parseInt(this.route.params.id)
const ret = this.getAllTickets.find(ticket => ticket.id === id);
return ret ? ret : {};
},
shippingEmail() {
@ -124,14 +124,15 @@ export default {
},
},
mounted() {
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(()=>{
if (this.ticket.state == "pending_new"){
this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket)
};
this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to
})]);
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
if (this.ticket.state == "pending_new") {
this.selected_state = "pending_open";
this.changeTicketStatus(this.ticket)
}
;
this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to
})]);
}
};
</script>

View file

@ -1,11 +1,12 @@
<template>
<AsyncLoader :loaded="tickets.length > 0">
<AsyncLoader :loaded="isTicketsLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to', 'actions', 'actions2']"
:items="tickets.map(formatTicket)"
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:items="getEventTickets.map(formatTicket)"
:keyName="'id'"
v-if="layout === 'table'"
>
@ -21,8 +22,9 @@
</Table>
</div>
</div>
<CollapsableCards v-if="layout === 'tasks'" :items="tickets"
:columns="['id', 'name', 'last_activity', 'assigned_to']"
<CollapsableCards v-if="layout === 'tasks'" :items="getEventTickets"
:columns="['id', 'name', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
<template #section_header="{index, section, count}">
@ -34,6 +36,7 @@
<td>{{ item.name }}</td>
<td>{{ item.last_activity }}</td>
<td>{{ item.assigned_to }}</td>
<td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td>
<div class="btn-group">
<a class="btn btn-primary" :href="'/'+ getEventSlug + '/ticket/' + item.id" title="view"
@ -64,8 +67,7 @@ export default {
name: 'Tickets',
components: {AsyncLoader, Lightbox, Table, Cards, Modal, EditItem, CollapsableCards},
computed: {
...mapState(['tickets']),
...mapGetters(['stateInfo', 'getEventSlug', 'layout']),
...mapGetters(['getEventTickets', 'isTicketsLoaded', 'stateInfo', 'getEventSlug', 'layout']),
},
methods: {
...mapActions(['loadTickets', 'fetchTicketStates', 'scheduleAfterInit']),
@ -79,7 +81,8 @@ export default {
state: this.stateInfo(ticket.state).text,
stateColor: this.stateInfo(ticket.state).color,
last_activity: ticket.last_activity,
assigned_to: ticket.assigned_to
assigned_to: ticket.assigned_to,
event: ticket.event
};
}
},