Compare commits

..

14 commits

Author SHA1 Message Date
f00afb86d6 stash
All checks were successful
/ test (push) Successful in 57s
2024-11-21 02:16:00 +01:00
4f08a2c265 stash 2024-11-21 01:54:19 +01:00
f51c367ead stash 2024-11-21 01:52:18 +01:00
124713b98c save raw_mails as file 2024-11-21 01:52:18 +01:00
f9e8ce1178 stash 2024-11-21 01:52:18 +01:00
6a99e7420f stash 2024-11-21 01:52:18 +01:00
6a81b1f832 stash 2024-11-21 01:52:18 +01:00
b6a8e3acd4 stash 2024-11-21 01:52:14 +01:00
420d22bb43 stash 2024-11-21 01:51:39 +01:00
f785189304 stash 2024-11-21 01:47:12 +01:00
9320765bc5 stash 2024-11-21 01:47:12 +01:00
33d368b5f4 stash 2024-11-21 01:47:12 +01:00
7083f4f7b7 stash 2024-11-21 01:47:12 +01:00
75ea0f4b46 stash 2024-11-21 01:47:12 +01:00
26 changed files with 182 additions and 606 deletions

View file

@ -20,12 +20,9 @@ jobs:
- name: Run django tests
working-directory: core
run: python3 manage.py test
- name: Run django coverage
working-directory: core
run: coverage manage.py test
deploy:
needs: [ test ]
needs: [test]
runs-on: docker
steps:
- uses: actions/checkout@v4

View file

@ -56,7 +56,6 @@ TELEGRAM_GROUP_CHAT_ID = os.getenv('TELEGRAM_GROUP_CHAT_ID', '-1234567890')
# Application definition
INSTALLED_APPS = [
*(['daphne'] if 'runserver' in sys.argv else []),
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
@ -130,7 +129,7 @@ TEMPLATES = [
},
]
ASGI_APPLICATION = 'core.asgi.application'
WSGI_APPLICATION = 'core.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

View file

@ -1,24 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-21 22:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0002_alter_file_file'),
]
def set_creation_date(apps, schema_editor):
File = apps.get_model('files', 'File')
for file in File.objects.all():
if file.created_at is None:
if not file.item.created_at is None:
file.created_at = file.item.created_at
else:
file.created_at = max(File.objects.filter(
id__lt=file.id, created_at__isnull=False).values_list('created_at', flat=True))
file.save()
operations = [
migrations.RunPython(set_creation_date),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 4.2.7 on 2024-11-20 01:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_alter_event_table'),
]
operations = [
migrations.RemoveField(
model_name='item',
name='container',
),
migrations.AlterField(
model_name='item',
name='event',
field=models.ForeignKey(db_column='eid', on_delete=django.db.models.deletion.CASCADE, to='inventory.event'),
),
migrations.CreateModel(
name='ItemPlacement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('container', models.ForeignKey(db_column='cid', on_delete=django.db.models.deletion.CASCADE, related_name='item_history', to='inventory.container')),
('item', models.ForeignKey(db_column='iid', on_delete=django.db.models.deletion.CASCADE, related_name='container_history', to='inventory.item')),
],
),
]

View file

@ -1,36 +0,0 @@
# Generated by Django 4.2.7 on 2024-11-23 00:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_alter_event_table'),
]
operations = [
migrations.RemoveField(
model_name='item',
name='container',
),
migrations.CreateModel(
name='ItemPlacement',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_history', to='inventory.container')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='container_history', to='inventory.item')),
],
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.AutoField(primary_key=True, serialize=False)),
('comment', models.TextField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='inventory.item')),
],
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 4.2.7 on 2024-11-20 01:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0007_remove_item_container_alter_item_event_itemplacement'),
]
operations = [
migrations.AlterField(
model_name='item',
name='event',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.event'),
),
migrations.AlterField(
model_name='itemplacement',
name='container',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_history', to='inventory.container'),
),
migrations.AlterField(
model_name='itemplacement',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='container_history', to='inventory.item'),
),
]

View file

@ -1,6 +1,7 @@
from itertools import groupby
from django.db import models
from django.core.files.base import ContentFile
from django.db import models, IntegrityError
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
@ -47,6 +48,8 @@ class Item(SoftDeleteModel):
groups = groupby(self.issue_relation_changes.all(), lambda rel: rel.issue_thread.id)
return [sorted(v, key=lambda r: r.timestamp)[0].issue_thread for k, v in groups]
objects = ItemManager()
all_objects = models.Manager()
@ -70,7 +73,7 @@ class Container(SoftDeleteModel):
def items(self):
try:
history = self.item_history.order_by('-timestamp').all()
return [v for k, v in groupby(history, key=lambda item: item.item.id)]
return [v for k, v in groupby(history, key=lambda item: item.item.id)]
except AttributeError:
return []
@ -85,16 +88,6 @@ class ItemPlacement(models.Model):
timestamp = models.DateTimeField(auto_now_add=True)
class Comment(models.Model):
id = models.AutoField(primary_key=True)
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='comments')
comment = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self):
return str(self.issue_thread) + ' comment #' + str(self.id)
class Event(models.Model):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)
@ -110,4 +103,4 @@ class Event(models.Model):
return '[' + str(self.slug) + ']' + self.name
class Meta:
db_table = 'common_event'
db_table = 'common_event'

View file

@ -9,7 +9,24 @@ from mail.models import EventAddress
from tickets.shared_serializers import BasicIssueSerializer
# class EventAdressSerializer(serializers.ModelSerializer):
# class Meta:
# model = EventAddress
# fields = ['address']
# def to_internal_value(self, data):
# if not isinstance(data, str):
# raise serializers.ValidationError('This field must be a string.')
#
# def create(self, validated_data):
# return EventAddress.objects.create(**validated_data)
#
# def validate(self, data):
# return isinstance(data, str)
class EventSerializer(serializers.ModelSerializer):
# addresses = EventAdressSerializer(many=True, required=False)
addresses = SlugRelatedField(many=True, slug_field='address', queryset=EventAddress.objects.all())
class Meta:
@ -17,13 +34,17 @@ class EventSerializer(serializers.ModelSerializer):
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
# def update(self, instance, validated_data):
# addresses = validated_data.pop('addresses', None)
# instance.save(validated_data)
# if addresses:
# for address in addresses:
# nested_instance, created = EventAddress.objects.get_or_create(address=address)
# instance.addresses.add(nested_instance)
#
# return instance
class ContainerSerializer(serializers.ModelSerializer):
itemCount = serializers.SerializerMethodField()
@ -38,14 +59,12 @@ class ContainerSerializer(serializers.ModelSerializer):
class ItemSerializer(BasicItemSerializer):
timeline = serializers.SerializerMethodField()
dataImage = serializers.CharField(write_only=True, required=False)
related_issues = BasicIssueSerializer(many=True, read_only=True)
class Meta:
model = Item
fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues',
'timeline']
fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues']
read_only_fields = ['id']
def to_internal_value(self, data):
@ -87,26 +106,6 @@ class ItemSerializer(BasicItemSerializer):
instance.files.add(file)
return super().update(instance, validated_data)
@staticmethod
def get_timeline(obj):
timeline = []
for comment in obj.comments.all():
timeline.append({
'type': 'comment',
'id': comment.id,
'timestamp': comment.timestamp,
'comment': comment.comment,
})
for relation in (obj.issue_relation_changes.all()):
timeline.append({
'type': 'issue_relation',
'id': relation.id,
'status': relation.status,
'timestamp': relation.timestamp,
'issue_thread': BasicIssueSerializer(relation.issue_thread).data,
})
return sorted(timeline, key=lambda x: x['timestamp'])
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()

View file

@ -24,7 +24,7 @@ class BasicItemSerializer(serializers.ModelSerializer):
def get_file(self, instance):
if len(instance.files.all()) > 0:
return sorted(instance.files.all(), key=lambda x: x.created_at, reverse=True)[0].hash
return instance.files.all().order_by('-created_at')[0].hash
return None
def get_returned(self, instance):

View file

@ -50,16 +50,14 @@ class EventTestCase(TestCase):
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']})
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')
#self.assertEqual(1, len(response.json()[0]['addresses']))
def test_remove_event(self):
event = Event.objects.create(slug='EVENT1', name='Event 1')
@ -69,26 +67,13 @@ class EventTestCase(TestCase):
self.assertEqual(response.status_code, 204)
self.assertEqual(len(Event.objects.all()), 1)
def test_event_with_address(self):
def test_items2(self):
from mail.models import EventAddress
event1 = Event.objects.create(slug='TEST1', name='Event')
EventAddress.objects.create(event=event1, address='foo@bar.baz')
EventAddress.objects.create(event=Event.objects.get(slug='TEST1'), address='foo@bar.baz')
response = self.client.get('/api/2/events/')
self.assertEqual(response.status_code, 200)
self.assertEqual(1, len(response.json()))
self.assertEqual('TEST1', response.json()[0]['slug'])
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])

View file

@ -1,5 +1,3 @@
from datetime import datetime, timedelta
from django.utils import timezone
from django.test import TestCase, Client
from django.contrib.auth.models import Permission
@ -7,12 +5,10 @@ from knox.models import AuthToken
from authentication.models import ExtendedUser
from files.models import File
from inventory.models import Event, Container, Item, Comment
from inventory.models import Event, Container, Item
from base64 import b64encode
from tickets.models import IssueThread, ItemRelation
class ItemTestCase(TestCase):
@ -24,29 +20,14 @@ class ItemTestCase(TestCase):
self.user.user_permissions.add(*Permission.objects.all())
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
self.issue = IssueThread.objects.create(
name="test issue",
event=self.event,
)
def test_empty(self):
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b'[]')
def test_members_and_timeline(self):
now = datetime.now()
def test_members(self):
item = Item.objects.create(container=self.box, event=self.event, description='1')
comment = Comment.objects.create(
item=item,
comment="test",
timestamp=now + timedelta(seconds=3),
)
match = ItemRelation.objects.create(
issue_thread=self.issue,
item = item,
timestamp=now + timedelta(seconds=5),
)
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
@ -57,24 +38,7 @@ class ItemTestCase(TestCase):
self.assertEqual(response.json()[0]['file'], None)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['timeline']), 2)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'issue_relation')
self.assertEqual(response.json()[0]['timeline'][0]['id'], comment.id)
self.assertEqual(response.json()[0]['timeline'][1]['id'], match.id)
self.assertEqual(response.json()[0]['timeline'][0]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][0]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][1]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['name'], "test issue")
self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['event'], "EVENT")
self.assertEqual(response.json()[0]['timeline'][1]['issue_thread']['state'], "pending_new")
self.assertEqual(len(response.json()[0]['related_issues']), 1)
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
self.assertEqual(response.json()[0]['related_issues'][0]['event'], "EVENT")
self.assertEqual(response.json()[0]['related_issues'][0]['state'], "pending_new")
self.assertEqual(len(response.json()[0]['related_issues']), 0)
def test_members_with_file(self):
import base64
@ -92,23 +56,6 @@ class ItemTestCase(TestCase):
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0)
def test_members_with_two_file(self):
import base64
item = Item.objects.create(container=self.box, event=self.event, description='1')
file1 = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8'))
file2 = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8'))
response = self.client.get(f'/api/2/{self.event.slug}/item/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 1)
self.assertEqual(response.json()[0]['id'], item.id)
self.assertEqual(response.json()[0]['description'], '1')
self.assertEqual(response.json()[0]['box'], 'BOX')
self.assertEqual(response.json()[0]['cid'], self.box.id)
self.assertEqual(response.json()[0]['file'], file2.hash)
self.assertEqual(response.json()[0]['returned'], False)
self.assertEqual(response.json()[0]['event'], self.event.slug)
self.assertEqual(len(response.json()[0]['related_issues']), 0)
def test_multi_members(self):
Item.objects.create(container=self.box, event=self.event, description='1')
Item.objects.create(container=self.box, event=self.event, description='2')

View file

@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2024-11-22 18:57
# Generated by Django 4.2.7 on 2024-11-18 01:50
from django.db import migrations, models

View file

@ -71,7 +71,7 @@ def make_reply(reply_email, references=None, event=None):
reply["From"] = reply_email.sender
reply["To"] = reply_email.recipient
reply["Subject"] = reply_email.subject
reply["Reply-To"] = f"{event.lower()}@{MAIL_DOMAIN}"
reply["Reply-To"] = f"{event}@{MAIL_DOMAIN}"
if reply_email.in_reply_to:
reply["In-Reply-To"] = reply_email.in_reply_to
if reply_email.reference:

View file

@ -21,8 +21,7 @@ from tickets.shared_serializers import RelationSerializer
class IssueViewSet(viewsets.ModelViewSet):
serializer_class = IssueSerializer
queryset = IssueThread.objects.all().prefetch_related('state_changes', 'comments', 'emails', 'emails__attachments',
'assignments', 'item_relation_changes', 'shipping_vouchers')
queryset = IssueThread.objects.all()
class RelationViewSet(viewsets.ModelViewSet):

View file

@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2024-11-20 01:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0007_remove_item_container_alter_item_event_itemplacement'),
('tickets', '0011_train_old_spam'),
]
operations = [
migrations.AlterField(
model_name='itemrelation',
name='item',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relations', to='inventory.item'),
),
]

View file

@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2024-11-20 23:58
# Generated by Django 4.2.7 on 2024-11-20 23:34
from django.db import migrations, models
import django.db.models.deletion
@ -7,8 +7,8 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0006_alter_event_table'),
('tickets', '0011_train_old_spam'),
('inventory', '0008_alter_item_event_alter_itemplacement_container_and_more'),
('tickets', '0012_alter_itemrelation_item'),
]
operations = [

View file

@ -52,11 +52,7 @@ class IssueThread(SoftDeleteModel):
@property
def state(self):
try:
state_changes = sorted(self.state_changes.all(), key=lambda x: x.timestamp, reverse=True)
if state_changes:
return state_changes[0].state
else:
return None
return self.state_changes.order_by('-timestamp').first().state
except AttributeError:
return 'none'
@ -71,11 +67,7 @@ class IssueThread(SoftDeleteModel):
@property
def assigned_to(self):
try:
assignments = sorted(self.assignments.all(), key=lambda x: x.timestamp, reverse=True)
if assignments:
return assignments[0].assigned_to
else:
return None
return self.assignments.order_by('-timestamp').first().assigned_to
except AttributeError:
return None

View file

@ -63,12 +63,14 @@ class IssueSerializer(BasicIssueSerializer):
@staticmethod
def get_last_activity(self):
try:
last_state_change = max([t.timestamp for t in self.state_changes.all()]) if self.state_changes.exists() else None
last_comment = max([t.timestamp for t in self.comments.all()]) if self.comments.exists() else None
last_mail = max([t.timestamp for t in self.emails.all()]) if self.emails.exists() else None
last_assignment = max([t.timestamp for t in self.assignments.all()]) if self.assignments.exists() else None
last_relation = max([t.timestamp for t in self.item_relation_changes.all()]) if self.item_relation_changes.exists() else None
last_state_change = self.state_changes.order_by('-timestamp').first().timestamp \
if self.state_changes.count() > 0 else None
last_comment = self.comments.order_by('-timestamp').first().timestamp if self.comments.count() > 0 else None
last_mail = self.emails.order_by('-timestamp').first().timestamp if self.emails.count() > 0 else None
last_assignment = self.assignments.order_by('-timestamp').first().timestamp if \
self.assignments.count() > 0 else None
last_relation = self.item_relation_changes.order_by('-timestamp').first().timestamp if \
self.item_relation_changes.count() > 0 else None
args = [x for x in [last_state_change, last_comment, last_mail, last_assignment, last_relation] if
x is not None]
return max(args)

View file

@ -5,7 +5,7 @@ from django.test import TestCase, Client
from authentication.models import ExtendedUser
from inventory.models import Event, Container, Item
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation
from django.contrib.auth.models import Permission
from knox.models import AuthToken
@ -53,24 +53,19 @@ class IssueApiTest(TestCase):
in_reply_to=mail1.reference,
timestamp=now + timedelta(seconds=2),
)
assignment = Assignment.objects.create(
issue_thread=issue,
assigned_to=self.user,
timestamp=now + timedelta(seconds=3),
)
comment = Comment.objects.create(
issue_thread=issue,
comment="test",
timestamp=now + timedelta(seconds=4),
timestamp=now + timedelta(seconds=3),
)
match = ItemRelation.objects.create(
issue_thread=issue,
item=self.item,
item = self.item,
timestamp=now + timedelta(seconds=5),
)
self.assertEqual('pending_new', issue.state)
self.assertEqual('test issue', issue.name)
self.assertEqual(self.user, issue.assigned_to)
self.assertEqual(None, issue.assigned_to)
self.assertEqual(36, len(issue.uuid))
response = self.client.get('/api/2/tickets/')
self.assertEqual(response.status_code, 200)
@ -79,16 +74,14 @@ class IssueApiTest(TestCase):
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'], self.user.username)
self.assertEqual(response.json()[0]['assigned_to'], None)
self.assertEqual(response.json()[0]['uuid'], issue.uuid)
self.assertEqual(response.json()[0]['last_activity'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['timeline']), 6)
self.assertEqual(len(response.json()[0]['timeline']), 5)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'mail')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'assignment')
self.assertEqual(response.json()[0]['timeline'][4]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][5]['type'], 'item_relation')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][1]['id'], mail1.id)
self.assertEqual(response.json()[0]['timeline'][2]['id'], mail2.id)
self.assertEqual(response.json()[0]['timeline'][3]['id'], comment.id)
@ -105,19 +98,15 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['timeline'][2]['body'], 'test')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['assigned_to'], self.user.username)
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
assignment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][4]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][5]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][5]['timestamp'],
self.assertEqual(response.json()[0]['timeline'][4]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][5]['item']['description'], "foo")
self.assertEqual(response.json()[0]['timeline'][5]['item']['event'], "evt")
self.assertEqual(response.json()[0]['timeline'][5]['item']['box'], "box1")
self.assertEqual(len(response.json()[0]['related_items']), 1)
self.assertEqual(response.json()[0]['timeline'][4]['item']['description'], "foo")
self.assertEqual(response.json()[0]['timeline'][4]['item']['event'], "evt")
self.assertEqual(response.json()[0]['timeline'][4]['item']['box'], "box1")
self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo")
self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt")
self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1")
@ -314,6 +303,16 @@ class IssueApiTest(TestCase):
content_type='application/json')
self.assertEqual(response.status_code, 400)
# def test_post_comment(self):
# issue = IssueThread.objects.create(
# name="test issue",
# )
# response = self.client.post('/api/2/comments/', {'comment': 'test', 'issue_thread': issue.id})
# self.assertEqual(response.status_code, 201)
# self.assertEqual(response.json()['comment'], 'test')
# self.assertEqual(response.json()['issue_thread'], issue.id)
# self.assertEqual(response.json()['timestamp'], response.json()['timestamp'])
def test_post_comment_altenative(self):
issue = IssueThread.objects.create(
name="test issue",

View file

@ -12,16 +12,13 @@
field="description"
:validation-fn="str => str && str.length > 0"
/>
<div class="form-group">
<label for="box">box</label>
<InputCombo
label="box"
:model="item"
nameKey="box"
uniqueKey="cid"
:options="boxes"
/>
</div>
<InputCombo
label="box"
:model="item"
nameKey="box"
uniqueKey="cid"
:options="boxes"
/>
</div>
</template>

View file

@ -1,222 +0,0 @@
<template>
<ol class="timeline">
<li v-for="(item, index) in timeline" :key="index"
:class="{'timeline-item':true, 'extra-space': item.type === 'mail'}">
<span class="timeline-item-icon filled-icon" v-if="item.type === 'mail'">
<font-awesome-icon icon="envelope"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'comment'">
<font-awesome-icon icon="comment"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'state'"
:class="'bg-' + stateInfo(item.state).color">
<font-awesome-icon :icon="stateInfo(item.state).icon"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'assignment'" :class="'bg-secondary'">
<font-awesome-icon icon="user"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'item_relation'">
<font-awesome-icon icon="object-group"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'shipping_voucher'">
<font-awesome-icon icon="truck"/>
</span>
<span class="timeline-item-icon faded-icon" v-else>
<font-awesome-icon icon="pen"/>
</span>
<TimelineMail v-if="item.type === 'mail'" :item="item"/>
<TimelineComment v-else-if="item.type === 'comment'" :item="item"/>
<TimelineStateChange v-else-if="item.type === 'state'" :item="item"/>
<TimelineAssignment v-else-if="item.type === 'assignment'" :item="item"/>
<TimelineRelatedItem v-else-if="item.type === 'item_relation'" :item="item"/>
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
<p v-else>{{ item }}</p>
</li>
<li class="timeline-item">
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="comment"/>
</span>
<div class="new-comment card bg-dark">
<div class="">
<textarea placeholder="add comment..." v-model="newComment" class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
</div>
</div>
</li>
</ol>
</template>
<script>
import TimelineMail from "@/components/TimelineMail.vue";
import TimelineComment from "@/components/TimelineComment.vue";
import TimelineStateChange from "@/components/TimelineStateChange.vue";
import {mapActions, mapGetters} from "vuex";
import TimelineAssignment from "@/components/TimelineAssignment.vue";
import TimelineRelatedItem from "@/components/TimelineRelatedItem.vue";
import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Timeline',
components: {
TimelineShippingVoucher, AsyncButton,
TimelineRelatedItem, TimelineAssignment, TimelineStateChange, TimelineComment, TimelineMail
},
props: {
timeline: {
type: Array,
default: () => []
}
},
emits: ['sendMail', 'addComment'],
data: () => ({
newMail: "",
newComment: ""
}),
computed: {
...mapGetters(['stateInfo']),
newestMailSubject() {
const mail = this.timeline.filter(item => item.type === 'mail').pop();
return mail ? mail.subject : "";
},
},
methods: {
...mapActions(['sendMail', 'postComment']),
sendMailAndClear: async function () {
await this.sendMail(this.newMail);
this.newMail = "";
},
addCommentAndClear: async function () {
await this.postComment(this.newComment);
this.newComment = "";
}
}
};
</script>
<style lang="scss" scoped>
*,
*:before,
*:after {
box-sizing: border-box;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
}
img {
display: block;
max-width: 100%;
}
/* End basic CSS override */
.timeline {
width: 85%;
display: flex;
flex-direction: column;
padding: 32px 0 32px 32px;
border-left: 2px solid var(--gray);
font-size: 1.125rem;
margin: 0 auto;
}
.timeline-item {
display: flex;
gap: 24px;
& + * {
margin-top: 24px;
}
& + .extra-space {
margin-top: 48px;
}
}
.new-comment, .new-mail {
width: 100%;
textarea, input {
border: 1px solid var(--gray);
border-radius: 6px;
height: 5em;
padding: 8px 16px;
&::placeholder {
color: var(--gray-dark);
}
&:focus {
border-color: var(--gray-dark);
outline: 0; /* Don't actually do this */
box-shadow: 0 0 0 4px var(--dark);
}
}
}
.timeline-item-icon {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border-radius: 50%;
margin-left: -57px;
flex-shrink: 0;
overflow: hidden;
svg {
width: 20px;
height: 20px;
}
&.faded-icon {
background-color: var(--secondary);
color: var(--light);
}
&.filled-icon {
background-color: var(--primary);
color: var(--light);
}
}
.button {
border: 0;
display: inline-flex;
vertical-align: middle;
margin-right: 4px;
margin-top: 12px;
align-items: center;
justify-content: center;
font-size: 1rem;
height: 32px;
padding: 0 8px;
background-color: var(--dark);
flex-shrink: 0;
cursor: pointer;
border-radius: 99em;
&:hover {
background-color: var(--gray);
}
}
</style>

View file

@ -1,4 +1,6 @@
<template>
<div class="form-group">
<label :for="label">{{ label }}</label>
<div class="input-group">
<div class="input-group-prepend">
<button
@ -32,6 +34,7 @@
</div>
<Addon type="Combo Box" :is-valid="isValid"/>
</div>
</div>
</template>
<script>

View file

@ -18,7 +18,6 @@ import Settings from "@/views/admin/Settings.vue";
import AccessControl from "@/views/admin/AccessControl.vue";
import {default as BoxesAdmin} from "@/views/admin/Boxes.vue"
import Shipping from "@/views/admin/Shipping.vue";
import Item from "@/views/Item.vue";
import Notifications from "@/views/admin/Notifications.vue";
const routes = [
@ -31,7 +30,7 @@ const routes = [
{requiresAuth: true, requiresPermission: 'view_item'}
},
{
path: '/:event/item/:id/', name: 'item', component: Item, meta:
path: '/:event/item/:uid/', name: 'item', component: Items, meta:
{requiresAuth: true, requiresPermission: 'view_item'}
},
{

View file

@ -151,11 +151,13 @@ const store = createStore({
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[updatedItem.event ? updatedItem.event : 'none'].filter(
@ -171,11 +173,13 @@ const store = createStore({
setTickets(state, {slug, tickets}) {
state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets};
console.log(state.loadedTickets)
},
replaceTickets(state, tickets) {
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.loadedTickets[updatedTicket.event ? updatedTicket.event : 'none'].filter(

View file

@ -1,134 +0,0 @@
<template>
<AsyncLoader :loaded="!!item.id">
<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>Item #{{ item.id }} - {{ item.description }}</h3>
</div>
<div>
{{ item }}
</div>
<ItemTimeline :timeline="item.timeline" @sendMail="handleMail" @addComment="handleComment"/>
<div class="card-footer d-flex justify-content-between">
<button class="btn btn-secondary mr-2" @click="$router.go(-1)">Back</button>
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="openEditingModalWith(item)"
title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-outline-danger"
@click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
<InputCombo
label="box"
:model="item"
nameKey="box"
uniqueKey="cid"
:options="boxes"
/>
<div class="btn-group">
<select class="form-control" v-model="selected_state">
<option v-for="status in state_options" :value="status.value">{{
status.text
}}
</option>
</select>
<button class="form-control btn btn-success"
@click="changeTicketStatus(item)"
:disabled="(selected_state == item.state)">
Change&nbsp;Status
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapState} from 'vuex';
import ItemTimeline from "@/components/ItemTimeline.vue";
import ClipboardButton from "@/components/inputs/ClipboardButton.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import InputCombo from "@/components/inputs/InputCombo.vue";
import InputString from "@/components/inputs/InputString.vue";
export default {
name: 'Item',
components: {InputString, InputCombo, AsyncLoader, ClipboardButton, ItemTimeline},
data() {
return {
selected_state: null,
selected_assignee: null,
shipping_voucher_type: null,
}
},
computed: {
...mapState(['state_options', 'users']),
...mapGetters(['availableShippingVoucherTypes', 'getAllItems', 'route', 'getBoxes']),
item() {
const id = parseInt(this.route.params.id)
const ret = this.getAllItems.find(item => item.id === id);
return ret ? ret : {};
},
boxes() {
console.log(this.getBoxes);
return this.getBoxes.map(obj => ({cid: obj.cid, box: obj.name}));
}
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers', 'loadEventItems', 'loadBoxes']),
handleMail(mail) {
this.sendMail({
id: this.item.id,
message: mail
})
},
handleComment(comment) {
this.postComment({
id: this.item.id,
message: comment
})
},
changeTicketStatus(item) {
item.state = this.selected_state;
this.updateTicketPartial({
id: item.id,
state: this.selected_state,
})
},
//assignTicket(item) {
// item.assigned_to = this.selected_assignee;
// this.updateTicketPartial({
// id: item.id,
// assigned_to: this.selected_assignee
// })
//},
},
mounted() {
this.scheduleAfterInit(() => [Promise.all([this.loadEventItems(), this.loadBoxes()]).then(() => {
this.selected_state = this.item.state;
this.selected_assignee = this.item.assigned_to
})]);
}
};
</script>
<style scoped>
</style>

View file

@ -20,7 +20,7 @@
:columns="['uid', 'description', 'box']"
:items="getEventItems"
:keyName="'uid'"
@itemActivated="showItemDetail"
@itemActivated="openLightboxModalWith($event)"
>
<template #actions="{ item }">
<div class="btn-group">
@ -47,7 +47,7 @@
:items="getEventItems"
:keyName="'uid'"
v-slot="{ item }"
@itemActivated="showItemDetail"
@itemActivated="openLightboxModalWith($event)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
@ -88,7 +88,6 @@ import {mapActions, mapGetters, mapState} from 'vuex';
import Lightbox from '../components/Lightbox';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'Items',
@ -103,9 +102,6 @@ export default {
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
showItemDetail(item) {
router.push({name: 'item', params: {id: item.id}});
},
openLightboxModalWith(item) {
this.lightboxHash = item.file;
},