Compare commits

..

No commits in common. "906adbe0db5c83b753482781d8b3da9b357df496" and "b575926d5275ed5f3632336298b002ea92964dd3" have entirely different histories.

30 changed files with 292 additions and 769 deletions

26
TODO.md
View file

@ -71,30 +71,8 @@
* [ ] concept: purge old tickets
* [ ] concept: purge old items
* [ ] concept: auto email stale after x days
* [ ] frontend: reply email field multiline
* [ ] concept: customisable autoreply (message, title) + ticket id
* [ ] frontend: show from address in issueThread or internal user
* [ ] manual ticket creation
* [ ] hide citation for incoming mails
* [ ] frontend: different icons for send and received mails
* [ ] frontend: change ticket status
* new mail -> new,
* op reply ->
* waiting for detail
* waiting for address / shipment information
* customer reply -> open
* needs physical confirmation
* needs to be shipped
* dhl mail -> closed: shipped
* closed: not found
* closed: not our problem
* closed: timeout (no reply)
* closed: duplicate
* closed: spam
* [ ] concept: split ticket for multiple items
* [ ] mail signature
* [ ] guru api integration
## Priority: TODO
* manual ticket creation
* send mails from web frontend
* login / user management

View file

@ -7,7 +7,6 @@ from django.contrib.auth import login
from django.urls import path
from django.dispatch import receiver
from django.db.models.signals import post_save
from django.contrib.auth.models import Group
from knox.models import AuthToken
from knox.views import LoginView as KnoxLoginView
@ -15,16 +14,9 @@ from authentication.models import ExtendedUser
class UserSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
class Meta:
model = ExtendedUser
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
def get_permissions(self, obj):
return list(set(obj.get_permissions()))
fields = ('id', 'username', 'email', 'first_name', 'last_name')
@receiver(post_save, sender=ExtendedUser)
@ -38,27 +30,7 @@ class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
members = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ('id', 'name', 'permissions', 'members')
def get_permissions(self, obj):
return ["*:" + p.codename for p in obj.permissions.all()]
def get_members(self, obj):
return [u.username for u in obj.user_set.all()]
class GroupViewSet(viewsets.ModelViewSet):
queryset = Group.objects.all()
serializer_class = GroupSerializer
@api_view(['GET'])
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def selfUser(request):
serializer = UserSerializer(request.user)
@ -107,7 +79,6 @@ class LoginView(KnoxLoginView):
router = routers.SimpleRouter()
router.register(r'users', UserViewSet, basename='users')
router.register(r'groups', GroupViewSet, basename='groups')
urlpatterns = router.urls + [
path('self/', selfUser),

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2023-12-13 02:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0001_initial'),
('authentication', '0004_legacy_user'),
]
operations = [
migrations.AlterField(
model_name='eventpermission',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='inventory.event'),
),
]

View file

@ -65,6 +65,7 @@ class PermissionsTestCase(TestCase):
user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2'))
user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1'))
user.save()
print(user.get_all_permissions())
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1')))
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2')))
#self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1')))

View file

@ -1,30 +1,17 @@
from django.test import TestCase, Client
from django.contrib.auth.models import Permission, Group
from django.contrib.auth.models import Permission
from knox.models import AuthToken
from authentication.models import ExtendedUser, EventPermission
from authentication.models import ExtendedUser
from core import settings
from inventory.models import Event
class UserApiTest(TestCase):
def setUp(self):
self.event = Event.objects.create(name='testevent', slug='testevent')
self.group1 = Group.objects.create(name='testgroup1')
self.group2 = Group.objects.create(name='testgroup2')
self.group1.permissions.add(Permission.objects.get(codename='add_item'))
self.group1.permissions.add(Permission.objects.get(codename='view_item'))
self.group2.permissions.add(Permission.objects.get(codename='view_event'))
self.group2.permissions.add(Permission.objects.get(codename='view_item'))
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(Permission.objects.get(codename='add_event'))
self.user.groups.add(self.group1)
self.user.groups.add(self.group2)
self.user.save()
EventPermission.objects.create(event=self.event, user=self.user,
permission=Permission.objects.get(codename='delete_item'))
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]})
@ -38,28 +25,18 @@ class UserApiTest(TestCase):
self.assertEqual(response.json()[0]['first_name'], '')
self.assertEqual(response.json()[0]['last_name'], '')
self.assertEqual(response.json()[0]['id'], 1)
self.assertEqual(response.json()[0]['groups'], [])
self.assertEqual(response.json()[1]['username'], 'testuser')
self.assertEqual(response.json()[1]['email'], 'test')
self.assertEqual(response.json()[1]['first_name'], '')
self.assertEqual(response.json()[1]['last_name'], '')
self.assertEqual(response.json()[1]['id'], 2)
self.assertEqual(response.json()[1]['groups'], ['testgroup1', 'testgroup2'])
def test_self_user(self):
response = self.client.get('/api/2/self/')
response = self.client.post('/api/2/self/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['username'], 'testuser')
self.assertEqual(response.json()['email'], 'test')
self.assertEqual(response.json()['first_name'], '')
self.assertEqual(response.json()['last_name'], '')
permissions = response.json()['permissions']
self.assertEqual(len(permissions), 5)
self.assertTrue('*:add_item' in permissions)
self.assertTrue('*:view_item' in permissions)
self.assertTrue('*:view_event' in permissions)
self.assertTrue('testevent:delete_item' in permissions)
self.assertTrue('*:add_event' in permissions)
def test_register_user(self):
anonymous = Client()
@ -135,49 +112,3 @@ class UserApiTest(TestCase):
content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertTrue('token' in response.json())
class GroupApiTest(TestCase):
def setUp(self):
self.event = Event.objects.create(name='testevent', slug='testevent')
# Admin, Orga, Team, User are created by default
self.group1 = Group.objects.create(name='testgroup1')
self.group2 = Group.objects.create(name='testgroup2')
self.group1.permissions.add(Permission.objects.get(codename='add_item'))
self.group1.permissions.add(Permission.objects.get(codename='view_item'))
self.group2.permissions.add(Permission.objects.get(codename='view_event'))
self.group2.permissions.add(Permission.objects.get(codename='view_item'))
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(Permission.objects.get(codename='add_event'))
self.user.groups.add(self.group1)
self.user.groups.add(self.group2)
self.user.save()
EventPermission.objects.create(event=self.event, user=self.user,
permission=Permission.objects.get(codename='delete_item'))
self.user.save()
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_groups(self):
response = self.client.get('/api/2/groups/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 6)
self.assertEqual(response.json()[0]['name'], 'Admin')
self.assertEqual(response.json()[1]['name'], 'Orga')
self.assertEqual(response.json()[2]['name'], 'Team')
self.assertEqual(response.json()[3]['name'], 'User')
self.assertEqual(response.json()[4]['name'], 'testgroup1')
self.assertEqual(response.json()[5]['name'], 'testgroup2')
def test_group(self):
response = self.client.get('/api/2/groups/5/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['name'], 'testgroup1')
permissions = response.json()['permissions']
self.assertEqual(len(permissions), 2)
self.assertTrue('*:add_item' in permissions)
self.assertTrue('*:view_item' in permissions)
members = response.json()['members']
self.assertEqual(len(members), 1)
self.assertEqual(members[0], 'testuser')

View file

@ -29,7 +29,7 @@ SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost'), 'c3lf.de']
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')

View file

@ -97,7 +97,7 @@ class ItemSerializer(serializers.ModelSerializer):
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@permission_required('view_item', raise_exception=True)
@permission_required('inventory.view_item', raise_exception=True)
def search_items(request, event_slug, query):
try:
event = Event.objects.get(slug=event_slug)
@ -117,11 +117,11 @@ def item(request, event_slug):
try:
event = Event.objects.get(slug=event_slug)
if request.method == 'GET':
if not request.user.has_event_perm(event, 'view_item'):
if not request.user.has_event_perm(event, 'inventory.view_item'):
return Response(status=403)
return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data)
elif request.method == 'POST':
if not request.user.has_event_perm(event, 'add_item'):
if not request.user.has_event_perm(event, 'inventory.add_item'):
return Response(status=403)
validated_data = ItemSerializer(data=request.data)
if validated_data.is_valid():
@ -131,34 +131,25 @@ def item(request, event_slug):
return Response(status=404)
@api_view(['GET', 'PUT', 'DELETE', 'PATCH'])
@api_view(['GET', 'PUT', 'DELETE'])
@permission_classes([IsAuthenticated])
def item_by_id(request, event_slug, id):
try:
event = Event.objects.get(slug=event_slug)
item = Item.objects.get(event=event, uid=id)
if request.method == 'GET':
if not request.user.has_event_perm(event, 'view_item'):
if not request.user.has_event_perm(event, 'inventory.view_item'):
return Response(status=403)
return Response(ItemSerializer(item).data)
elif request.method == 'PUT':
if not request.user.has_event_perm(event, 'change_item'):
if not request.user.has_event_perm(event, 'inventory.change_item'):
return Response(status=403)
validated_data = ItemSerializer(item, data=request.data)
if validated_data.is_valid():
validated_data.save()
return Response(validated_data.data)
return Response(validated_data.errors, status=400)
elif request.method == 'PATCH':
if not request.user.has_event_perm(event, 'change_item'):
return Response(status=403)
validated_data = ItemSerializer(item, data=request.data, partial=True)
if validated_data.is_valid():
validated_data.save()
return Response(validated_data.data)
return Response(validated_data.errors, status=400)
elif request.method == 'DELETE':
if not request.user.has_event_perm(event, 'delete_item'):
if not request.user.has_event_perm(event, 'inventory.delete_item'):
return Response(status=403)
item.delete()
return Response(status=204)

View file

@ -1,18 +1,16 @@
import logging
from django.urls import re_path
from django.urls import path
from django.contrib.auth.decorators import permission_required
from rest_framework import routers, viewsets, serializers, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
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
@ -82,44 +80,9 @@ def reply(request, pk):
return Response({'status': 'ok'}, status=status.HTTP_201_CREATED)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread_manual', raise_exception=True)
def manual_ticket(request):
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:
return Response({'status': 'error', 'message': 'missing sender'}, status=status.HTTP_400_BAD_REQUEST)
if 'recipient' not in request.data:
return Response({'status': 'error', 'message': 'missing recipient'}, status=status.HTTP_400_BAD_REQUEST)
if 'body' not in request.data:
return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST)
issue = IssueThread.objects.create(
name=request.data['name'],
manually_created=True,
)
email = Email.objects.create(
issue_thread=issue,
sender=request.data['sender'],
recipient=request.data['recipient'],
subject=request.data['name'],
body=request.data['body'],
)
systemevent = SystemEvent.objects.create(type='email received', reference=email.id)
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"}
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues')
urlpatterns = ([
re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
] + router.urls)
urlpatterns = router.urls + [
path('tickets/<int:pk>/reply/', reply, name='reply'),
]

View file

@ -1,22 +0,0 @@
# Generated by Django 4.2.7 on 2023-12-22 20:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='issuethread',
options={'permissions': [('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually')]},
),
migrations.AddField(
model_name='issuethread',
name='manually_created',
field=models.BooleanField(default=False),
),
]

View file

@ -10,12 +10,10 @@ class IssueThread(SoftDeleteModel):
state = models.CharField(max_length=255, default='new')
assigned_to = models.CharField(max_length=255, null=True)
last_activity = models.DateTimeField(auto_now=True)
manually_created = models.BooleanField(default=False)
class Meta:
permissions = [
('send_mail', 'Can send mail'),
('add_issuethread_manual', 'Can add issue thread manually'),
]

View file

@ -5,7 +5,7 @@ from django.test import TestCase, Client
from authentication.models import ExtendedUser
from mail.models import Email
from tickets.models import IssueThread, StateChange, Comment
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from knox.models import AuthToken
@ -14,7 +14,6 @@ class IssueApiTest(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]})
@ -92,19 +91,3 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
def test_manual_creation(self):
response = self.client.post('/api/2/tickets/manual/', {'name': 'test issue', 'sender': 'test',
'recipient': 'test', 'body': 'test'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['state'], 'new')
self.assertEqual(response.json()['name'], 'test issue')
self.assertEqual(response.json()['assigned_to'], None)
timeline = response.json()['timeline']
self.assertEqual(len(timeline), 1)
self.assertEqual(timeline[0]['type'], 'mail')
self.assertEqual(timeline[0]['sender'], 'test')
self.assertEqual(timeline[0]['recipient'], 'test')
self.assertEqual(timeline[0]['subject'], 'test issue')
self.assertEqual(timeline[0]['body'], 'test')

View file

@ -1,13 +1,12 @@
<template>
<div id="app">
<AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/>
<AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/>
<Navbar v-if="isLoggedIn" @addItemClicked="openAddItemModal()" @addTicketClicked="openAddTicketModal()"/>
<AddItemModal v-if="addModalOpen && isLoggedIn" @close="closeAddModal()" isModal="true"/>
<Navbar v-if="isLoggedIn" @addClicked="openAddModal()"/>
<router-view/>
<div aria-live="polite" aria-atomic="true" v-if="isLoggedIn"
class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3"
style="min-height: 200px; z-index: 100000; pointer-events: none">
<Toast v-for="(toast , index) in toasts" :key="index" :title="toast.title" :message="toast.message"
<Toast v-for="toast in toasts" :key="toast" :title="toast.title" :message="toast.message"
:color="toast.color"
@close="removeToast(toast.key)" style="pointer-events: auto"/>
</div>
@ -19,35 +18,27 @@ import Navbar from '@/components/Navbar';
import AddItemModal from '@/components/AddItemModal';
import Toast from './components/Toast';
import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
import AddTicketModal from "@/components/AddTicketModal.vue";
export default {
name: 'app',
components: {Toast, Navbar, AddItemModal, AddTicketModal},
components: {Toast, Navbar, AddItemModal},
computed: {
...mapState(['loadedItems', 'layout', 'toasts']),
...mapGetters(['isLoggedIn']),
},
data: () => ({
addItemModalOpen: false,
addTicketModalOpen: false,
addModalOpen: false,
notify_socket: null,
socket_toast: null,
}),
methods: {
...mapMutations(['removeToast', 'createToast']),
...mapActions(['loadEventItems', 'loadTickets']),
openAddItemModal() {
this.addItemModalOpen = true;
openAddModal() {
this.addModalOpen = true;
},
openAddTicketModal() {
this.addTicketModalOpen = true;
},
closeAddItemModal() {
this.addItemModalOpen = false;
},
closeAddTicketModal() {
this.addTicketModalOpen = false;
closeAddModal() {
this.addModalOpen = false;
},
tryConnect() {
if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) {

View file

@ -1,49 +0,0 @@
<template>
<div>
<Modal v-if="isModal" title="Add Item" @close="$emit('close')">
<template #body>
<div>
<input type="text" class="form-control" placeholder="Sender" v-model="ticket.sender">
<input type="text" class="form-control" placeholder="Title" v-model="ticket.title">
<textarea class="form-control" placeholder="Message" v-model="ticket.message"></textarea>
</div>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
<button type="button" class="btn btn-success" @click="saveNewTicket()">Save new Item</button>
</template>
</Modal>
</div>
</template>
<script>
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
export default {
name: 'AddTicketModal',
components: {Modal, EditItem},
props: ['isModal'],
data: () => ({
ticket: {
sender: '',
message: '',
title: '',
}
}),
created() {
this.ticket = {box: this.$store.state.lastUsed.box || '', cid: this.$store.state.lastUsed.cid || ''};
},
methods: {
saveNewTicket() {
this.$store.dispatch('postManualTicket', this.ticket).then(() => {
this.$emit('close');
});
}
}
};
</script>
<style scoped>
</style>

View file

@ -1,45 +0,0 @@
<template>
<table class="table table-striped table-dark" style="table-layout: fixed">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" v-for="(column, index) in columns" :key="index">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td>{{ row }}</td>
<td v-for="(column, j) in columns" :key="j">
<font-awesome-icon v-if="items[j][i]" icon="check" class="text-success"/>
<font-awesome-icon v-else icon="times" class="text-danger"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
name: 'Matrix2D',
props: {
items: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
rows: {
type: Array,
required: true
}
},
};
</script>
<style>
</style>

View file

@ -1,61 +0,0 @@
<template>
<table class="table table-striped table-dark" style="table-layout: fixed">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" v-for="(column, index) in columns" :key="index">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td>{{ row }}</td>
<td v-for="(column, j) in columns" :key="j">
<font-awesome-icon v-if="triState(items[j][i]) === 'on'" icon="check" class="text-success"/>
<font-awesome-icon v-else-if="triState(items[j][i]) === 'off'" icon="times" class="text-danger"/>
<font-awesome-icon v-else icon="minus" class="text-warning"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
name: 'Matrix3D',
props: {
items: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
rows: {
type: Array,
required: true
},
foldedDimension: {
type: Array,
required: true
}
},
methods: {
triState(list) {
if(list.every(e => e === true)) {
return 'on';
} else if(list.every(e => e === false)) {
return 'off';
} else {
return 'partial';
}
}
},
};
</script>
<style>
</style>

View file

@ -11,25 +11,25 @@
</div>
</div>
<ul class="nav nav-tabs flex-nowrap">
<li class="nav-item" v-if="checkPermission(getEventSlug, 'view_item')">
<li class="nav-item">
<router-link :to="{name: 'items', params: {event: getEventSlug}}"
:class="['nav-link', { active: isItemView() }]">
:class="['nav-link', { active: getActiveView === 'items' || getActiveView === 'item' }]">
Items
</router-link>
</li>
<li class="nav-item" v-if="checkPermission(getEventSlug, 'view_issuethread')">
<li class="nav-item" v-if="checkRole('team')">
<router-link :to="{name: 'tickets', params: {event: getEventSlug}}"
:class="['nav-link', { active: isTicketView() }]">
:class="['nav-link', { active: getActiveView === 'tickets' || getActiveView === 'ticket' }]">
Tickets
</router-link>
</li>
<li class="nav-item" v-if="checkPermission(getEventSlug, 'delete_event')">
<router-link :to="{name: 'admin'}" class="nav-link" active-class="active">
<li class="nav-item" v-if="checkRole('admin')">
<router-link :to="{name: 'admin'}" :class="['nav-link', { active: getActiveView === 'admin' }]">
Admin
</router-link>
</li>
</ul>
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1" v-if="hasPermissions">
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline">
<input
class="form-control w-100"
type="search"
@ -39,8 +39,8 @@
disabled
>
</form>
<div class="custom-control-inline mr-1" v-if="hasPermissions">
<div class="btn-group btn-group-toggle mr-1" v-if="isItemView()">
<div class="custom-control-inline mr-1">
<div class="btn-group btn-group-toggle mx-1">
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
<font-awesome-icon icon="th"/>
</button>
@ -48,15 +48,9 @@
<font-awesome-icon icon="list"/>
</button>
</div>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')"
v-if="isItemView()">
<button type="button" class="btn text-nowrap btn-success" @click="$emit('addClicked')">
<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()">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Ticket</span>
<span class="d-none d-md-inline">&nbsp;Add</span>
</button>
</div>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
@ -83,13 +77,9 @@
{{ link.title }}
</a>
</li>
<li class="nav-item">
<a class="nav-link text-nowrap" href="/logout" @click.prevent="logout()">
Logout
</a>
</li>
</ul>
</div>
</nav>
</template>
@ -109,21 +99,14 @@ export default {
]
}),
computed: {
...mapState(['events', 'layout']),
...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions"]),
...mapState(['events', 'activeEvent', 'layout']),
...mapGetters(['getEventSlug', 'getActiveView', "checkRole"]),
},
methods: {
...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
...mapMutations(['setLayout', 'logout']),
...mapMutations(['setLayout']),
navigateTo(link) {
if (this.$router.currentRoute.path !== link)
this.$router.push(link);
},
isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item';
},
isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
}
}
};

View file

@ -25,8 +25,8 @@
</span>
<div class="new-comment">
<div class="input-group">
<input type="text" placeholder="reply mail..." v-model="newMail">
<button class="btn btn-primary" @click="sendMail">
<input type="text" placeholder="Add a comment..." v-model="newMail">
<button class="btn" @click="sendMail">
Send
</button>
</div>

View file

@ -13,13 +13,13 @@
</div>
<div class="card-body">
<p><span v-html="body"></span></p>
<!--button class="button">👏 2</button>
<button class="button">👏 2</button>
<button class="button | square">
<font-awesome-icon icon="user"/>
</button-->
</button>
</div>
</div>
<!--button class="show-replies">
<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">
@ -38,7 +38,7 @@
<font-awesome-icon icon="user"/>
</i>
</span>
</button-->
</button>
</div>
</template>

View file

@ -34,17 +34,14 @@ import {
faComment,
faEnvelope,
faUser,
faComments,
faArchive,
faMinus,
faComments
} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
import vueDebounce from 'vue-debounce';
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);
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope);
Vue.component('font-awesome-icon', FontAwesomeIcon);
//import VueQRCodeComponent from 'vue-qrcode-component'

View file

@ -1,6 +1,7 @@
import Items from './views/Items';
import Boxes from './views/Boxes';
import Files from './views/Files';
import Events from './views/Events';
import Error from './views/Error';
import HowTo from './views/HowTo';
import VueRouter from 'vue-router';
@ -12,51 +13,25 @@ import Tickets from "@/views/Tickets.vue";
import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue";
import store from "@/store";
import Empty from "@/views/Empty.vue";
import Events from "@/views/admin/Events.vue";
import AccessControl from "@/views/admin/AccessControl.vue";
Vue.use(VueRouter);
const routes = [
{path: '/', redirect: '/Camp23/items', meta: {requiresAuth: false}},
{path: '/login/', name: 'login', component: Login, meta: {requiresAuth: false}},
{path: '/register/', name: 'register', component: Register, meta: {requiresAuth: false}},
{path: '/howto/', name: 'howto', component: HowTo, meta: {requiresAuth: true}},
{path: '/:event/items/', name: 'items', component: Items, meta:
{requiresAuth: true, requiresPermission: 'view_item'}},
{path: '/:event/item/:uid/', name: 'item', component: Items, meta:
{requiresAuth: true, requiresPermission: 'view_item'}},
{path: '/:event/boxes/', name: 'boxes', component: Boxes, meta:
{requiresAuth: true, requiresPermission: 'view_container'}},
{path: '/:event/box/:uid/', name: 'box', component: Boxes, meta:
{requiresAuth: true, requiresPermission: 'view_container'}},
{path: '/:event/tickets/', name: 'tickets', component: Tickets, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}},
{path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}},
{path: '/admin/', component: Admin, meta:
{requiresAuth: true, requiresPermission: 'delete_event'},
children: [
{
path: 'files/', name: 'files', component: Files, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'events/', name: 'events', component: Events, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: '', name: 'admin', component: Debug, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'users/', name: 'users', component: AccessControl, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
]
},
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
{path: '/login', name: 'login', component: Login, meta: {requiresAuth: false}},
{path: '/register', name: 'register', component: Register, meta: {requiresAuth: false}},
{path: '/howto', name: 'howto', component: HowTo, meta: {requiresAuth: true}},
{path: '/:event/boxes', name: 'boxes', component: Boxes, meta: {requiresAuth: true}},
{path: '/:event/items', name: 'items', component: Items, meta: {requiresAuth: true}},
{path: '/:event/box/:uid', name: 'box', component: Boxes, meta: {requiresAuth: true}},
{path: '/:event/item/:uid', name: 'item', component: Items, meta: {requiresAuth: true}},
{path: '/:event/tickets', name: 'tickets', component: Tickets, meta: {requiresAuth: true}},
{path: '/:event/ticket/:id', name: 'ticket', component: Ticket, meta: {requiresAuth: true}},
{path: '/admin', name: 'admin', component: Admin, meta: {requiresAuth: true}},
{path: '/admin/files', name: 'files', component: Files, meta: {requiresAuth: true}},
{path: '/admin/events', name: 'events', component: Events, meta: {requiresAuth: true}},
{path: '/admin/debug', name: 'debug', component: Debug, meta: {requiresAuth: true}},
{path: '/admin/users', name: 'users', component: Events, meta: {requiresAuth: true}},
{path: '*', component: Error},
];
@ -82,16 +57,11 @@ const router = new VueRouter({
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
console.log("Not logged in, redirecting to login page")
//console.log("Not logged in, redirecting to login page")
next({
name: 'login',
query: {redirect: to.fullPath},
})
} else if (to.meta.requiresPermission && !store.getters.checkPermission(to.params.event || "*", to.meta.requiresPermission)) {
console.log("Not enough permissions, redirecting to empty page")
next({
path: '/user',
})
} else {
next()
}

View file

@ -63,18 +63,14 @@ const store = new Vuex.Store({
events: [],
layout: 'cards',
loadedItems: [],
itemCache: {},
loadedBoxes: [],
toasts: [],
tickets: [],
users: [],
groups: [],
userRoles: ['admin', 'team', 'orga', 'user'],
lastEvent: localStorage.getItem('lf_lastEvent') || '36C3',
lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'),
remember: false,
user: null,
password: null,
userPermissions: [],
token: null,
token_expiry: null,
local_loaded: false,
@ -84,13 +80,11 @@ const store = new Vuex.Store({
getActiveView: state => state.route.name || 'items',
getFilters: state => state.route.query,
getBoxes: state => state.loadedBoxes,
checkPermission: state => (event, perm) => state.userPermissions.includes(`${event}:${perm}`) || state.userPermissions.includes(`*:${perm}`),
hasPermissions: state => state.userPermissions.length > 0,
checkRole: state => role => state.userRoles.includes(role),
isLoggedIn(state) {
if (!state.local_loaded) {
state.remember = localStorage.getItem('remember') === 'true'
state.user = localStorage.getItem('user')
state.userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]')
state.token = localStorage.getItem('token')
state.token_expiry = localStorage.getItem('token_expiry')
state.local_loaded = true
@ -117,9 +111,6 @@ const store = new Vuex.Store({
replaceLoadedItems(state, newItems) {
state.loadedItems = newItems;
},
setItemCache(state, {slug, items}) {
state.itemCache[slug] = items;
},
setLayout(state, layout) {
state.layout = layout;
},
@ -139,12 +130,6 @@ const store = new Vuex.Store({
replaceTickets(state, tickets) {
state.tickets = tickets;
},
replaceUsers(state, users) {
state.users = users;
},
replaceGroups(state, groups) {
state.groups = groups;
},
updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket);
@ -167,14 +152,6 @@ const store = new Vuex.Store({
if (user)
localStorage.setItem('user', user);
},
setPassword(state, password) {
state.password = password;
},
setPermissions(state, permissions) {
state.userPermissions = permissions;
if (permissions)
localStorage.setItem('permissions', JSON.stringify(permissions));
},
setToken(state, {token, expiry}) {
state.token = token;
state.token_expiry = expiry;
@ -186,10 +163,8 @@ const store = new Vuex.Store({
state.user = null;
state.token = null;
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('token');
localStorage.removeItem('token_expiry');
if (router.currentRoute.name !== 'login')
router.push('/login');
},
},
@ -206,7 +181,6 @@ const store = new Vuex.Store({
if (data.token) {
commit('setToken', data);
commit('setUser', username);
commit('setPassword', password);
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
dispatch('afterLogin');
return true;
@ -220,11 +194,10 @@ const store = new Vuex.Store({
},
async reloadToken({commit, state}) {
try {
if (data.password) {
const data = await fetch('/api/2/login/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: state.user, password: state.password}),
body: JSON.stringify({username: state.user, password: state.token}),
credentials: 'omit'
}).then(r => r.json())
if (data.token) {
@ -232,34 +205,28 @@ const store = new Vuex.Store({
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
return true;
}
}
} catch (e) {
console.error(e);
}
//credentials failed, logout
store.commit('logout');
router.push('/login');
},
async afterLogin({dispatch}) {
const boxes = dispatch('loadBoxes');
const items = dispatch('loadEventItems');
const tickets = dispatch('loadTickets');
const user = dispatch('loadUserInfo');
await Promise.all([boxes, items, tickets, user]);
await dispatch('loadBoxes');
await dispatch('loadEventItems');
await dispatch('loadTickets');
},
async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
},
async loadUserInfo({commit}) {
const {data} = await axios.get('/2/self/');
commit('setUser', data.username);
commit('setPermissions', data.permissions);
},
async loadEvents({commit}) {
const {data} = await axios.get('/2/events/');
commit('replaceEvents', data);
},
changeEvent({dispatch, getters, commit}, eventName) {
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
commit('replaceLoadedItems', []);
dispatch('loadEventItems');
},
changeView({getters}, link) {
@ -268,16 +235,10 @@ const store = new Vuex.Store({
showBoxContent({getters}, box) {
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
},
async loadEventItems({commit, getters, state}) {
async loadEventItems({commit, getters}) {
try {
commit('replaceLoadedItems', []);
const slug = getters.getEventSlug;
if (slug in state.itemCache) {
commit('replaceLoadedItems', state.itemCache[slug]);
}
const {data} = await axios.get(`/2/${slug}/items/`);
const {data} = await axios.get(`/2/${getters.getEventSlug}/items/`);
commit('replaceLoadedItems', data);
commit('setItemCache', {slug, items: data});
} catch (e) {
console.error("Error loading items");
}
@ -298,7 +259,7 @@ const store = new Vuex.Store({
commit('updateItem', data);
},
async markItemReturned({commit, getters}, item) {
await axios.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
commit('removeItem', item);
},
async deleteItem({commit, getters}, item) {
@ -317,19 +278,7 @@ const store = new Vuex.Store({
async sendMail({commit, dispatch}, {id, message}) {
const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message});
await dispatch('loadTickets');
},
async postManualTicket({commit, dispatch}, {sender, message, title,}) {
const {data} = await axios.post(`/2/tickets/manual/`, {name: title, sender, body: message, recipient: 'mail@c3lf.de'});
await dispatch('loadTickets');
},
async loadUsers({commit}) {
const {data} = await axios.get('/2/users/');
commit('replaceUsers', data);
},
async loadGroups({commit}) {
const {data} = await axios.get('/2/groups/');
commit('replaceGroups', data);
},
}
}
});

View file

@ -1,29 +0,0 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3 class="text-center">User: {{user}}</h3>
</div>
<div class="card-body">
<p>Your Account is not yet activated. Please contact an admin.</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapState} from "vuex";
export default {
name: 'Empty',
computed: mapState(['user']),
};
</script>
<style scoped>
</style>

View file

@ -1,18 +1,5 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3 class="text-center">Error</h3>
</div>
<div class="card-body">
<p>Something went wrong. <a href="/">Go back to the start page </a>or contact an admin.</p>
</div>
</div>
</div>
</div>
</div>
<p>Error</p>
</template>
<script>

41
web/src/views/Events.vue Normal file
View file

@ -0,0 +1,41 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['slug', 'name']"
:items="events"
:keyName="'slug'"
v-slot="{ item }"
>
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
<font-awesome-icon icon="archive"/>
use
</button>
<button class="btn btn-danger" @click.stop="">
<font-awesome-icon icon="trash"/>
delete
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Events',
components: {Table},
computed: mapState(['events']),
methods: mapActions(['changeEvent']),
};
</script>
<style scoped>
</style>

View file

@ -1,68 +0,0 @@
<template>
<div>
<h3 class="text-center">Users</h3>
<Table :items="users" :columns="['username']" :key-name="'id'">
<template v-slot:default="{item}">
<span>
{{item.groups.join(', ')}}
</span>
</template>
</Table>
<h3 class="text-center">Groups</h3>
<Table :items="groups" :columns="['name']" :key-name="'id'">
<template v-slot:default="{item}">
<span>
{{item.members.join(', ')}}
</span>
</template>
</Table>
<Matrix2D :items="groupPermissions" :columns="groupNames" :rows="permissionNames"/>
<h3 class="text-center">Permissions</h3>
<Matrix3D :items="userPermissions" :columns="userNames" :rows="permissionNames"
:folded-dimension="eventsNames"/>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from "@/components/Table.vue";
import Matrix3D from "@/components/Matrix3D.vue";
import Matrix2D from "@/components/Matrix2D.vue";
export default {
name: 'AccessControl',
components: {Matrix3D, Matrix2D, Table},
computed: {
...mapState(['users', 'groups', 'events']),
permissionNames() {
return this.groups.map(g => g.permissions).flat().map(p => p.split(":")[1])
},
groupNames() {
return this.groups.map(g => g.name)
},
eventsNames() {
return this.events.map(e => e.slug)
},
userNames() {
return this.users.map(u => u.username)
},
groupPermissions() {
return this.groups.map(g => this.permissionNames.map(p => g.permissions.includes("*:" + p)))
},
userPermissions() {
return this.users.map(u => this.permissionNames.map(p => this.events.map(e =>
u.permissions.includes("*:" + p) || u.permissions.includes(e.slug + ":" + p)
)))
},
},
methods: mapActions(['loadUsers', 'loadGroups']),
mounted() {
this.loadUsers();
this.loadGroups();
}
};
</script>
<style scoped>
</style>

View file

@ -3,23 +3,22 @@
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'admin'}" active-class="active" exact>Dashboard</router-link>
<h3 class="text-center">Admin</h3>
<ul>
<li>
<router-link :to="{name: 'debug'}">Debug</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link>
<li>
<router-link :to="{name: 'boxes', params: {event: getEventSlug}}">Boxes</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :to="{name: 'users'}" active-class="active">Access Control</router-link>
<li>
<router-link :to="{name: 'events'}">Events</router-link>
</li>
<li>
<router-link :to="{name: 'users'}">Users</router-link>
</li>
</ul>
</div>
<div class="card-body">
<router-view></router-view>
</div>
</div>
</div>
</div>
</div>
@ -27,11 +26,9 @@
<script>
import {mapGetters} from 'vuex';
import Cards from "@/components/Cards.vue";
export default {
name: 'Admin',
components: {Cards},
computed: {
...mapGetters(['getEventSlug']),
},

View file

@ -1,4 +1,7 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['cid', 'name','itemCount']"
:items="loadedBoxes"
@ -14,6 +17,9 @@
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>

View file

@ -1,5 +1,7 @@
<template>
<div>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code-->
<h3 class="text-center">Events</h3>
<!--p>{{ events }}</p-->
@ -44,15 +46,18 @@
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
import Events from "@/views/Events.vue";
export default {
name: 'Debug',
components: {Table},
components: {Events, Table},
computed: {
...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
qr_url() {

View file

@ -1,4 +1,7 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['slug', 'name']"
:items="events"
@ -16,6 +19,9 @@
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>

View file

@ -0,0 +1,29 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<h3 class="text-center">Users</h3>
<p>{{ users }}</p>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
import Events from "@/views/Events.vue";
export default {
name: 'Users',
computed: mapState(['users']),
methods: mapActions(['loadUsers']),
mounted() {
this.loadUsers();
}
};
</script>
<style scoped>
</style>