diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py index 9a611f6..2611ce3 100644 --- a/core/inventory/serializers.py +++ b/core/inventory/serializers.py @@ -9,20 +9,22 @@ from mail.models import EventAddress from tickets.shared_serializers import BasicIssueSerializer -class EventAdressSerializer(serializers.ModelSerializer): - class Meta: - model = EventAddress - fields = ['address'] - - class EventSerializer(serializers.ModelSerializer): - addresses = EventAdressSerializer(many=True, required=False) + addresses = SlugRelatedField(many=True, slug_field='address', queryset=EventAddress.objects.all()) class Meta: model = Event fields = ['id', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses'] read_only_fields = ['id'] + def to_internal_value(self, data): + data = data.copy() + addresses = data.pop('addresses', None) + dict = super().to_internal_value(data) + if addresses: + dict['addresses'] = [EventAddress.objects.get_or_create(address=x)[0] for x in addresses] + return dict + class ContainerSerializer(serializers.ModelSerializer): itemCount = serializers.SerializerMethodField() diff --git a/core/inventory/tests/v2/test_events.py b/core/inventory/tests/v2/test_events.py index 48ddb01..bd58515 100644 --- a/core/inventory/tests/v2/test_events.py +++ b/core/inventory/tests/v2/test_events.py @@ -47,6 +47,20 @@ class EventTestCase(TestCase): self.assertEqual(Event.objects.all()[0].slug, 'EVENT2') self.assertEqual(Event.objects.all()[0].name, 'Event 2 new') + def test_update_event(self): + from rest_framework.test import APIClient + event = Event.objects.create(slug='EVENT1', name='Event 1') + response = APIClient().patch(f'/api/2/events/{event.id}/', {'addresses': ['foo@bar.baz', 'foo1@bar.baz']}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['slug'], 'EVENT1') + self.assertEqual(response.json()['name'], 'Event 1') + self.assertEqual(2, len(response.json()['addresses'])) + self.assertEqual('foo@bar.baz', response.json()['addresses'][0]) + self.assertEqual('foo1@bar.baz', response.json()['addresses'][1]) + self.assertEqual(len(Event.objects.all()), 1) + self.assertEqual(Event.objects.all()[0].slug, 'EVENT1') + self.assertEqual(Event.objects.all()[0].name, 'Event 1') + def test_remove_event(self): event = Event.objects.create(slug='EVENT1', name='Event 1') Event.objects.create(slug='EVENT2', name='Event 2') @@ -66,3 +80,15 @@ class EventTestCase(TestCase): self.assertEqual('Event', response.json()[0]['name']) self.assertEqual(1, len(response.json()[0]['addresses'])) + def test_items_remove_addresss(self): + from mail.models import EventAddress + from rest_framework.test import APIClient + event1 = Event.objects.create(slug='TEST1', name='Event') + EventAddress.objects.create(event=event1, address='foo@bar.baz') + EventAddress.objects.create(event=event1, address='fo1o@bar.baz') + response = APIClient().patch(f'/api/2/events/{event1.id}/', {'addresses': ['foo1@bar.baz']}) + self.assertEqual(response.status_code, 200) + self.assertEqual('TEST1', response.json()['slug']) + self.assertEqual('Event', response.json()['name']) + self.assertEqual(1, len(response.json()['addresses'])) + self.assertEqual('foo1@bar.baz', response.json()['addresses'][0]) diff --git a/web/src/components/ExpandableTable.vue b/web/src/components/ExpandableTable.vue new file mode 100644 index 0000000..6bd5175 --- /dev/null +++ b/web/src/components/ExpandableTable.vue @@ -0,0 +1,124 @@ +<template> + <table class="table table-striped table-dark"> + <thead> + <tr> + <th> + </th> + <th scope="col" v-for="(column, index) in columns" :key="index"> + <div class="input-group"> + <div class="input-group-prepend"> + <button + :class="[ 'btn', column === sortBy ? 'btn-outline-info' : 'btn-outline-secondary' ]" + @click="toggleSort(column)" + > + {{ column }} + <span :class="{ 'text-info': column === sortBy }"> + <font-awesome-icon :icon="getSortIcon(column)"/> + </span> + </button> + </div> + <input + type="text" + class="form-control" + placeholder="filter" + :value="filters[column]" + @input="changeFilter(column, $event.target.value)" + > + </div> + </th> + <th> + <slot name="header_actions"/> + </th> + </tr> + </thead> + <tbody v-for="(item, index) in internalItems" :key="item[keyName]" style="border-top: none"> + <tr @click="toggle(index)"> + <td> + <font-awesome-icon icon="angle-right" style="width: 1em;" v-if="collapsed[index]"/> + <font-awesome-icon icon="angle-down" style="width: 1em;" v-else/> + </td> + <td v-for="(column, index) in columns" :key="index">{{ item[column] }}</td> + <td> + <slot v-bind:id="index" v-bind:item="item" name="actions"/> + </td> + </tr> + <tr v-if="!collapsed[index]"> + <td :colspan="columns.length + 2"> + <slot v-bind:id="index" v-bind:item="item" name="detail"/> + </td> + </tr> + </tbody> + </table> +</template> + +<script> +import DataContainer from '@/mixins/data-container'; +import router from '../router'; +import {mapGetters} from "vuex"; + +export default { + name: 'ExpandableTable', + mixins: [DataContainer], + created() { + this.columns.map(e => ({ + k: e, + v: this.$store.getters.getFilters[e] + })).filter(e => e.v).forEach(e => this.setFilter(e.k, e.v)); + + const query = this.route ? (this.route.query ? this.route.query.collapsed : null) : null; + if (query !== null && query !== undefined) { + this.collapsed = this.unpackInt(parseInt(query), this.internalItems.length); + } else { + this.collapsed = this.internalItems.map(() => true); + } + }, + data() { + return { + collapsed: [], + }; + }, + computed: { + ...mapGetters(['route']), + }, + methods: { + changeFilter(col, val) { + this.setFilter(col, val); + let newquery = Object.entries({ + ...this.$store.getters.getFilters, + [col]: val + }).reduce((a, [k, v]) => (v ? {...a, [k]: v} : a), {}); + router.push({query: newquery}); + }, + packInt(arr) { + return arr.reduce((a, e, i) => a + (e ? 0 : 2 ** i), 0); + }, + unpackInt(n, l) { + return [...Array(l)].map((e, i) => (n & 2 ** i) === 0); + }, + toggle(index) { + const collapsed = [...this.collapsed] + collapsed[index] = !collapsed[index]; + this.collapsed = collapsed; + }, + }, + watch: { + collapsed: { + handler() { + const encoded = this.packInt(this.collapsed).toString() + if (this.route.query.collapsed !== encoded) + this.$router.push({ + ...this.route, + query: {...this.route.query, collapsed: encoded} + }); + }, + deep: true, + }, + }, +}; +</script> + +<style> +.table-body-move { + transition: transform 1s; +} +</style> \ No newline at end of file diff --git a/web/src/store.js b/web/src/store.js index dcc3aea..60dba61 100644 --- a/web/src/store.js +++ b/web/src/store.js @@ -339,6 +339,13 @@ const store = createStore({ commit('replaceEvents', [...state.events.filter(e => e.eid !== event_id)]) } }, + async updateEvent({commit, dispatch, state}, {id, partial_event}){ + console.log(id, partial_event); + const {data, success} = await http.patch(`/2/events/${id}/`, partial_event, state.user.token); + if (success) { + commit('replaceEvents', [...state.events.filter(e => e.eid !== id), data]) + } + }, async fetchTicketStates({commit, state, getters}) { if (!state.user.token) return; if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return; diff --git a/web/src/views/admin/Events.vue b/web/src/views/admin/Events.vue index 1aab608..e2ff952 100644 --- a/web/src/views/admin/Events.vue +++ b/web/src/views/admin/Events.vue @@ -1,50 +1,102 @@ <template> - <Table - :columns="['slug', 'name']" - :items="events" - :keyName="'slug'" - > - <template v-slot:header_actions> - <button class="btn btn-success" @click.prevent="openAddEventModal"> - <font-awesome-icon icon="plus"/> - Create Event - </button> - </template> - <template v-slot:actions="{ item }"> - <div class="btn-group"> - <button class="btn btn-secondary" @click.stop="changeEvent(item)"> - <font-awesome-icon icon="archive"/> - use + <AsyncLoader :loaded="events.length > 0"> + <ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events" :keyName="'slug'"> + <template v-slot:header_actions> + <button class="btn btn-success" @click.prevent="openAddEventModal"> + <font-awesome-icon icon="plus"/> + Create Event </button> - <button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)"> - <font-awesome-icon icon="trash"/> - delete - </button> - </div> - </template> - </Table> + </template> + <template v-slot:actions="{ item }"> + <div class="btn-group"> + <button class="btn btn-secondary" @click.stop="changeEvent(item)" style="white-space: nowrap;"> + <font-awesome-icon icon="archive"/> use + </button> + <button class="btn btn-danger" @click.stop="safeDeleteEvent(item.eid)" style="white-space: nowrap;"> + <font-awesome-icon icon="trash"/> delete + </button> + </div> + </template> + <template v-slot:detail="{id, item }"> + <div class="row"> + <div class="col"> + <input type="date" class="form-control form-control-sm" title="Buildup Start" + v-model="item.pre_start" disabled style="opacity: 1" + :max="item.start" @focus="prepare_date_field"> + </div> + <div class="col"> + <input type="date" class="form-control form-control-sm" title="Official Event Start" + v-model="item.start" disabled style="opacity: 1" + :min="item.pre_start" :max="item.end" @focus="prepare_date_field"> + </div> + <div class="col"> + <input type="date" class="form-control form-control-sm" title="Official Event End" + v-model="item.end" disabled style="opacity: 1" + :min="item.start" :max="item.post_end" @focus="prepare_date_field"> + </div> + <div class="col"> + <input type="date" class="form-control form-control-sm" title="Teardown End" + v-model="item.post_end" disabled style="opacity: 1" + :min="item.end" @focus="prepare_date_field"> + </div> + </div> + <div class="mt-3"> + <label class="mr-3">Addresses: </label> + <div v-for="(address, a_id) in item.addresses" class="btn-group btn-group-sm mr-3" + @click.stop="deleteAddress(id, a_id)"> + <button class="btn btn-secondary" disabled style="opacity: 1"> + {{ address }} + </button> + <button class="btn btn-danger"> + <font-awesome-icon icon="trash"/> + </button> + </div> + <div class="btn-group btn-group-sm"> + <input type="text" v-model="new_address[id]"> + <button class="btn btn-secondary" @click.stop="addAddress(id)" style="white-space: nowrap;"> + <font-awesome-icon icon="envelope"/> add + </button> + </div> + </div> + </template> + </ExpandableTable> + </AsyncLoader> </template> <script> import {mapActions, mapMutations, mapState} from 'vuex'; -import Table from '@/components/Table'; +import ExpandableTable from "@/components/ExpandableTable.vue"; +import AsyncLoader from "@/components/AsyncLoader.vue"; export default { name: 'Events', - components: {Table}, + components: {AsyncLoader, ExpandableTable}, computed: mapState(['events']), + data() { + return {new_address: []} + }, methods: { - ...mapActions(['changeEvent', 'deleteEvent']), + ...mapActions(['changeEvent', 'deleteEvent', 'updateEvent']), ...mapMutations(['openAddEventModal']), safeDeleteEvent(id) { if (confirm('do you want to completely delete this event and related data?')) { this.deleteEvent(id) } }, + addAddress(id) { + const a = this.new_address[id]; + if (!this.events[id].addresses.includes(a)) + this.events[id].addresses.push(a) + this.new_address[id] = "" + this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}}); + }, + deleteAddress(id, a_id) { + this.events[id].addresses = this.events[id].addresses.filter((e, i) => i !== a_id); + this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}}); + } }, }; </script> <style scoped> - </style> \ No newline at end of file