Compare commits

..

1 commit

Author SHA1 Message Date
374abe885b also deploy live branch automatically
All checks were successful
/ test (push) Successful in 51s
2024-12-05 00:56:37 +01:00
58 changed files with 361 additions and 11282 deletions

View file

@ -35,7 +35,7 @@ jobs:
- name: Populate relevant files
run: |
mkdir -p ~/.ssh
mkdir ~/.ssh
echo "${{ secrets.C3LF_SSH_TESTING }}" > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
ls -lah ~/.ssh
@ -43,7 +43,7 @@ jobs:
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
echo "andromeda.lab.or.it ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXPoO0PE+B9PYwbGaLo98zhbmjAkp6eBtVeZe43v/+T" >> ~/.ssh/known_hosts
mkdir -p /etc/ansible
mkdir /etc/ansible
echo "${{ secrets.C3LF_INVENTORY_TESTING }}" > /etc/ansible/hosts
- name: Check ansible version

View file

@ -0,0 +1,60 @@
on:
push:
branches:
- live
jobs:
test:
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache-dependency-path: '**/requirements.dev.txt'
- name: Install dependencies
working-directory: core
run: pip3 install -r requirements.dev.txt
- name: Run django tests
working-directory: core
run: python3 manage.py test
deploy:
needs: [test]
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install ansible
run: |
apt update -y
apt install python3-pip -y
python3 -m pip install ansible
python3 -m pip install ansible-lint
- name: Populate relevant files
run: |
mkdir ~/.ssh
echo "${{ secrets.C3LF_SSH_LIVE }}" > ~/.ssh/id_ed25519
chmod 0600 ~/.ssh/id_ed25519
ls -lah ~/.ssh
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519
echo "polaris.lab.or.it ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO//BP4rx4Aav0voFicFSoYEG4VPfrbcWwd2JFfItRZT" >> ~/.ssh/known_hosts
mkdir /etc/ansible
echo "${{ secrets.C3LF_INVENTORY_LIVE }}" > /etc/ansible/hosts
- name: Check ansible version
run: |
ansible --version
- name: List ansible hosts
run: |
ansible -m ping Polaris
- name: Deploy testing
run: |
cd deploy/ansible
ansible-playbook playbooks/deploy-c3lf-sys3.yml

158
README.md
View file

@ -1,158 +0,0 @@
# C3LF System3
the third try to automate lost&found organization for chaos events. not a complete rewrite, but instead building on top
of the web frontend of version 2. everything else is new but still API compatible. now with more monorepo.
## Architecture
C3LF System3 integrates a Django-Rest-Framework + WebSocket backend, Vue.js frontend SPA and a minimal LMTP mail server
integrated with the Django backend. It is additionally deployed with a Postfix mail server as Proxy in front of the
LMTP socket, a MariaDB database, a Redis cache and an Nginx reverse proxy that serves the static SPA frontend, proxies
the API requests to the backend and serves the media files in cooperation with the Django backend using the
`X-Accel-Redirect` header.
The production deployment is automated using Ansible and there are some Docker Compose configurations for development.
## Project Structure
- `core/` Contains the Django backend with database models, API endpoints, migrations, API tests, and mail server
functionalities.
- `web/` Contains the Vue.js frontend application.
- `deploy/` Contains deployment configurations and Docker scripts for various development modes.
For more information, see the README.md files in the respective directories.
## Development Modes
There are currently 4 development modes for this Project:
- Frontend-Only
- Backend-API-Only
- Full-Stack-Lite 'dev' (docker)
- **[WIP]** Full-Stack 'testing' (docker)
*Choose the one that is most suited to the feature you want to work on or ist easiest for you to set up ;)*
For all modes it is assumed that you have `git` installed, have cloned the repository and are in the root directory of
the project. Use `git clone https://git.hannover.ccc.de/c3lf/c3lf-system-3.git` to get the official upstream repository.
The required packages for each mode are listed separately and also state the specific package name for Debian 12.
### Frontend-Only
This mode is for developing the frontend only. It uses the vue-cli-service (webpack) to serve the frontend and watches
for changes in the source code to provide hot reloading. The API requests are proxied to the staging backend.
#### Requirements
* Node.js (~20.19.0) (`nodejs`)
* npm (~9.2.0) (`npm`)
*Note: The versions are not strict, but these are tested. Other versions might work as well.*
#### Steps
```bash
cd web
npm intall
npm run serve
```
Now you can access the frontend at `localhost:8080` and start editing the code in the `web` directory.
For more information, see the README.md file in the `web` directory.
### Backend-API-Only
This mode is for developing the backend API only. It also specifically excludes most WebSockets and mail server
functionalities. Use this mode to focus on the backend API and Database models.
#### Requirements
* Python (~3.11) (`python3`)
* pip (`python3-pip`)
* virtualenv (`python3-venv`)
*Note: The versions are not strict, but these are tested. Other versions might work as well.*
#### Steps
```
python -m venv venv
source venv/bin/activate
pip install -r core/requirements.dev.txt
cd core
python manage.py test
```
The tests should run successfully to start and you can now start the TDD workflow by adding new failing tests.
For more information about the backend and TDD, see the README.md file in the `core` directory.
### Full-Stack-Lite 'dev' (docker)
This mode is for developing the both frontend and backend backend at the same time in a containerized environment. It
uses the `docker-compose` command to build and run the application in a container. It specifically excludes all mail
server and most WebSocket functionalities.
#### Requirements
* Docker (`docker.io`)
* Docker Compose (`docker-compose`)
*Note: Depending on your system, the `docker compose` command might be included in general `docker` or `docker-ce`
package, or you might want to use podman instead.*
#### Steps
```bash
docker-compose -f deploy/dev/docker-compose.yml up --build
```
The page should be available at [localhost:8080](http://localhost:8080)
This Mode provides a minimal set of testdata, including a user `testuser` with password `testuser`. The test dataset is
defined in deploy/testdata.py and can be extended there.
You can now edit code in `/web` and `/core` and changes will be applied to the running page as soon as the file is
saved.
For details about each part, read `/web/README.md` and `/core/README.md` respectively. To execute commands in the
container context use 'exec' like
```bash
docker exec -it c3lf-sys3-dev-core-1 ./manage.py test`
```
### Full-Stack 'testing' (docker)
**WORK IN PROGRESS**
*will include postfix, mariadb, redis, nginx and the ability to test sending mails, receiving mail and websocket based
realiteme updates in the frontend. the last step in verification before deploying to the staging system using ansible*
## Online Instances
These are deployed using `deploy/ansible/playbooks/deploy-c3lf-sys3.yml` and follow a specific git branch.
### 'live'
| URL | [c3lf.de](https://c3lf.de) |
|----------------|----------------------------|
| **Branch** | live |
| **Host** | polaris.lab.or.it |
| **Debug Mode** | off |
This is the **'production' system** and should strictly follow the staging system after all changes have been validated.
### 'staging'
| URL | [staging.c3lf.de](https://staging.c3lf.de) |
|----------------|--------------------------------------------|
| **Branch** | testing |
| **Host** | andromeda.lab.or.it |
| **Debug Mode** | on |
This system ist automatically updated by [git.hannover.ccc.de](https://git.hannover.ccc.de/c3lf/c3lf-system-3/) whenever
a commit is pushed to the 'testing' branch and the backend tests passed.
**WARNING: allthough this is the staging system, it is fully functional and contains a copy of the 'production' data, so
do not for example reply to tickets for testing purposes as the system WILL SEND AN EMAIL to the person who originally
created it. If you want to test something like that, first create you own test ticket by sending an email to
`<event>@staging.c3lf.de`**

View file

@ -1,68 +0,0 @@
# Core
This directory contains the backend of the C3LF System3 project, which is built using Django and Django Rest Framework.
## Modules
- `authentication`: Handles user authentication and authorization.
- `files`: Manages file uploads and related operations.
- `inventory`: Handles inventory management, including events, containers and items.
- `mail`: Manages email-related functionalities, including sending and receiving emails.
- `notify_sessions`: Handles real-time notifications and WebSocket sessions.
- `tickets`: Manages the ticketing system for issue tracking.
## Modules Structure
Most modules follow a similar structure, including the following components:
- `<module>/models.py`: Contains the database models for the module.
- `<module>/serializers.py`: Contains the serializers for the module models.
- `<module>/api_<api_version>.py`: Contains the API views and endpoints for the module.
- `<module>/migrations/`: Contains database migration files. Needs to contain an `__init__.py` file to be recognized as
a Python package and automatically migration creation to work.
- `<module>/tests/<api_version>/test_<feature_model_or_testcase>.py`: Contains the test cases for the module.
## Development Setup
follow the instructions under 'Backend-API-Only' or 'Fullstack-Lite' in the root level `README.md` to set up a
development environment.
## Test-Driven Development (TDD) Workflow
The project follows a TDD workflow to ensure code quality and reliability. Here is a step-by-step guide to the TDD
process:
1. **Write a Test**: Start by writing a test case for the new feature or bug fix. Place the test case in the appropriate
module within the `<module>/tests/<api_version>/test_<feature_model_or_testcase>.py` file.
2. **Run the Test**: Execute the test to ensure it fails, confirming that the feature is not yet implemented or the bug
exists.
```bash
python manage.py test
```
3. **Write the Code**: Implement the code required to pass the test. Write the code in the appropriate module within the
project.
4. **Run the Test Again**: Execute the test again to ensure it passes.
```bash
python manage.py test
```
5. **Refactor**: Refactor the code to improve its structure and readability while ensuring that all tests still pass.
6. **Repeat**: Repeat the process for each new feature or bug fix.
## Measuring Test Coverage
The project uses the `coverage` package to measure test coverage. To generate a coverage report, run the following
command:
```bash
coverage run --source='.' manage.py test
coverage report
```
## Additional Information
For more detailed information on the project structure and development modes, refer to the root level `README.md`.

View file

@ -1,40 +0,0 @@
from django.apps import apps
from prometheus_client.core import CounterMetricFamily, REGISTRY
from django.db.models import Case, Value, When, BooleanField, Count
from inventory.models import Item
class ItemCountCollector(object):
def collect(self):
try:
counter = CounterMetricFamily("item_count", "Current number of items", labels=['event', 'returned_state'])
yield counter
if not apps.models_ready or not apps.apps_ready:
return
queryset = (
Item.all_objects
.annotate(
returned=Case(
When(returned_at__isnull=True, then=Value(False)),
default=Value(True),
output_field=BooleanField()
)
)
.values('event__slug', 'returned', 'event_id')
.annotate(amount=Count('id'))
.order_by('event__slug', 'returned') # Optional: order by slug and returned
)
for e in queryset:
counter.add_metric([e["event__slug"].lower(), str(e["returned"])], e["amount"])
yield counter
except:
pass
REGISTRY.register(ItemCountCollector())

View file

@ -124,12 +124,19 @@ TEMPLATES = [
},
]
ASGI_APPLICATION = 'core.asgi.application'
WSGI_APPLICATION = 'core.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
if os.getenv('DB_HOST') is not None:
if 'test' in sys.argv:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
@ -142,20 +149,6 @@ if os.getenv('DB_HOST') is not None:
'charset': 'utf8mb4',
'init_command': "SET sql_mode='STRICT_TRANS_TABLES'"
}
},
}
elif os.getenv('DB_FILE') is not None:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.getenv('DB_FILE', 'local.db'),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}

View file

@ -19,8 +19,6 @@ from django.urls import path, include
from .version import get_info
from .metrics import *
urlpatterns = [
path('djangoadmin/', admin.site.urls),
path('api/2/', include('inventory.api_v2')),

View file

@ -1,5 +1,6 @@
from django.core.files.base import ContentFile
from django.db import models, IntegrityError
from django_softdelete.models import SoftDeleteModel
from inventory.models import Item
@ -9,8 +10,7 @@ def hash_upload(instance, filename):
class FileManager(models.Manager):
def __file_data_helper(self, **kwargs):
def get_or_create(self, **kwargs):
if 'data' in kwargs and type(kwargs['data']) == str:
import base64
from hashlib import sha256
@ -31,10 +31,6 @@ class FileManager(models.Manager):
pass
else:
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
return kwargs
def get_or_create(self, **kwargs):
kwargs = self.__file_data_helper(**kwargs)
try:
return self.get(hash=kwargs['hash']), False
except self.model.DoesNotExist:
@ -43,7 +39,26 @@ class FileManager(models.Manager):
return obj, True
def create(self, **kwargs):
kwargs = self.__file_data_helper(**kwargs)
if 'data' in kwargs and type(kwargs['data']) == str:
import base64
from hashlib import sha256
raw = kwargs['data']
if not raw.startswith('data:'):
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
raw = raw.split(';base64,')
if len(raw) != 2:
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
mime_type = raw[0].split(':')[1]
content = base64.b64decode(raw[1], validate=True)
kwargs.pop('data')
content_hash = sha256(content).hexdigest()
kwargs['file'] = ContentFile(content, content_hash)
kwargs['hash'] = content_hash
kwargs['mime_type'] = mime_type
elif 'file' in kwargs and 'hash' in kwargs and type(kwargs['file']) == ContentFile and 'mime_type' in kwargs:
pass
else:
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
if not self.filter(hash=kwargs['hash']).exists():
obj = super().create(**kwargs)
obj.file.save(content=kwargs['file'], name=kwargs['hash'])

View file

@ -39,61 +39,13 @@ class ItemViewSet(viewsets.ModelViewSet):
def filter_items(items, query):
query_tokens = query.split(' ')
matches = []
for item in items:
value = 0
if "I#" + str(item.id) in query:
value += 12
matches.append(
{'type': 'item_id', 'text': f'is exactly {item.id} and matched "I#{item.id}"'})
elif "#" + str(item.id) in query:
value += 11
matches.append(
{'type': 'item_id', 'text': f'is exactly {item.id} and matched "#{item.id}"'})
elif str(item.id) in query:
value += 10
matches.append({'type': 'item_id', 'text': f'is exactly {item.id}'})
for issue in item.related_issues:
if "T#" + issue.short_uuid() in query:
value += 8
matches.append({'type': 'ticket_uuid',
'text': f'is exactly {issue.short_uuid()} and matched "T#{issue.short_uuid()}"'})
elif "#" + issue.short_uuid() in query:
value += 5
matches.append({'type': 'ticket_uuid',
'text': f'is exactly {issue.short_uuid()} and matched "#{issue.short_uuid()}"'})
elif issue.short_uuid() in query:
value += 3
matches.append({'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()}'})
if "T#" + str(issue.id) in query:
value += 8
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "T#{issue.id}"'})
elif "#" + str(issue.id) in query:
value += 5
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "#{issue.id}"'})
elif str(issue.id) in query:
value += 3
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id}'})
for comment in issue.comments.all():
for token in query_tokens:
if token in comment.comment:
value += 1
matches.append({'type': 'ticket_comment', 'text': f'contains {token}'})
for token in query_tokens:
if token in issue.name:
value += 1
matches.append({'type': 'ticket_name', 'text': f'contains {token}'})
for token in query_tokens:
if token in item.description:
value += 1
matches.append({'type': 'item_description', 'text': f'contains {token}'})
for comment in item.comments.all():
for token in query_tokens:
if token in comment.comment:
value += 1
matches.append({'type': 'comment', 'text': f'contains {token}'})
if value > 0:
yield {'search_score': value, 'item': item, 'search_matches': matches}
yield {'search_score': value, 'item': item}
@api_view(['GET'])

View file

@ -1,9 +1,6 @@
from itertools import groupby
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils import timezone
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
@ -67,11 +64,6 @@ class Item(SoftDeleteModel):
return '[' + str(self.id) + ']' + self.description
@receiver(pre_save, sender=Item)
def item_updated(sender, instance, **kwargs):
instance.updated_at = timezone.now()
class Container(SoftDeleteModel):
id = models.AutoField(primary_key=True)
name = models.CharField(max_length=255)

View file

@ -132,33 +132,15 @@ class ItemSerializer(BasicItemSerializer):
'cid': placement.container.id,
'box': placement.container.name
})
if obj.created_at:
timeline.append({
'type': 'created',
'timestamp': obj.created_at,
})
if obj.returned_at:
timeline.append({
'type': 'returned',
'timestamp': obj.returned_at,
})
if obj.deleted_at:
timeline.append({
'type': 'deleted',
'timestamp': obj.deleted_at,
})
return sorted(timeline, key=lambda x: x['timestamp'])
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
search_matches = serializers.ListField(child=serializers.DictField())
item = ItemSerializer()
def to_representation(self, instance):
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score'],
'search_matches': instance['search_matches']}
return {**ItemSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = Item

View file

@ -63,28 +63,28 @@ 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']), 5)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'created')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'placement')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'issue_relation')
self.assertEqual(response.json()[0]['timeline'][4]['type'], 'placement')
self.assertEqual(response.json()[0]['timeline'][2]['id'], comment.id)
self.assertEqual(response.json()[0]['timeline'][3]['id'], match.id)
self.assertEqual(response.json()[0]['timeline'][4]['id'], placement.id)
self.assertEqual(response.json()[0]['timeline'][1]['box'], 'BOX1')
self.assertEqual(response.json()[0]['timeline'][1]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['timeline'][0]['timestamp'], item.created_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][2]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'], comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['name'], "test issue")
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['event'], "EVENT")
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['state'], "pending_new")
self.assertEqual(response.json()[0]['timeline'][4]['box'], 'BOX2')
self.assertEqual(response.json()[0]['timeline'][4]['cid'], self.box2.id)
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
self.assertEqual(len(response.json()[0]['timeline']), 4)
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'placement')
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'comment')
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'issue_relation')
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'placement')
self.assertEqual(response.json()[0]['timeline'][1]['id'], comment.id)
self.assertEqual(response.json()[0]['timeline'][2]['id'], match.id)
self.assertEqual(response.json()[0]['timeline'][3]['id'], placement.id)
self.assertEqual(response.json()[0]['timeline'][0]['box'], 'BOX1')
self.assertEqual(response.json()[0]['timeline'][0]['cid'], self.box1.id)
self.assertEqual(response.json()[0]['timeline'][1]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][2]['status'], 'possible')
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['name'], "test issue")
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['event'], "EVENT")
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['state'], "pending_new")
self.assertEqual(response.json()[0]['timeline'][3]['box'], 'BOX2')
self.assertEqual(response.json()[0]['timeline'][3]['cid'], self.box2.id)
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
placement.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
self.assertEqual(len(response.json()[0]['related_issues']), 1)
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")

View file

@ -48,21 +48,9 @@ def unescape_and_decode_base64(s):
return decoded
def unescape_simplified_quoted_printable(s, encoding='utf-8'):
def unescape_simplified_quoted_printable(s):
import quopri
return quopri.decodestring(s).decode(encoding)
def decode_inline_encodings(s):
s = unescape_and_decode_quoted_printable(s)
s = unescape_and_decode_base64(s)
return s
def ascii_strip(s):
if not s:
return None
return ''.join([c for c in str(s) if 128 > ord(c) > 31])
return quopri.decodestring(s).decode('utf-8')
def collect_references(issue_thread):
@ -93,17 +81,17 @@ def make_reply(reply_email, references=None, event=None):
reply_email.save()
if references:
reply["References"] = " ".join(references)
if reply_email.body != "":
reply.set_content(reply_email.body)
return reply
else:
raise SpecialMailException("mail content emty")
reply.set_content(reply_email.body)
return reply
async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
def find_active_issue_thread(in_reply_to, address, subject, event, spam=False):
def find_active_issue_thread(in_reply_to, address, subject, event):
from re import match
uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address)
if uuid_match:
@ -114,8 +102,7 @@ def find_active_issue_thread(in_reply_to, address, subject, event, spam=False):
if reply_to.exists():
return reply_to.first().issue_thread, False
else:
issue = IssueThread.objects.create(name=subject, event=event,
initial_state='pending_suspected_spam' if spam else 'pending_new')
issue = IssueThread.objects.create(name=subject, event=event)
return issue, True
@ -129,22 +116,6 @@ def find_target_event(address):
return None
def decode_email_segment(segment, charset, transfer_encoding):
decode_as = 'utf-8'
if charset == 'windows-1251':
decode_as = 'cp1251'
elif charset == 'iso-8859-1':
decode_as = 'latin1'
if transfer_encoding == 'quoted-printable':
segment = unescape_simplified_quoted_printable(segment, decode_as)
elif transfer_encoding == 'base64':
import base64
segment = base64.b64decode(segment).decode('utf-8')
else:
segment = decode_inline_encodings(segment.decode('utf-8'))
return segment
def parse_email_body(raw, log=None):
import email
from hashlib import sha256
@ -156,24 +127,21 @@ def parse_email_body(raw, log=None):
if parsed.is_multipart():
for part in parsed.walk():
ctype = part.get_content_type()
charset = part.get_content_charset()
cdispo = str(part.get('Content-Disposition'))
if ctype == 'multipart/mixed':
log.debug("Ignoring Multipart %s %s", ctype, cdispo)
# skip any text/plain (txt) attachments
elif ctype == 'text/plain' and 'attachment' not in cdispo:
if ctype == 'text/plain' and 'attachment' not in cdispo:
segment = part.get_payload()
if not segment:
continue
segment = decode_email_segment(segment.encode('utf-8'), charset, part.get('Content-Transfer-Encoding'))
segment = unescape_and_decode_quoted_printable(segment)
segment = unescape_and_decode_base64(segment)
if part.get('Content-Transfer-Encoding') == 'quoted-printable':
segment = unescape_simplified_quoted_printable(segment)
log.debug(segment)
body = body + segment
elif 'attachment' in cdispo or 'inline' in cdispo:
content = part.get_payload(decode=True)
if content is None:
continue
file = ContentFile(content)
file = ContentFile(part.get_payload(decode=True))
chash = sha256(file.read()).hexdigest()
name = part.get_filename()
if name is None:
@ -199,8 +167,10 @@ def parse_email_body(raw, log=None):
else:
log.warning("Unknown content type %s", parsed.get_content_type())
body = "Unknown content type"
body = decode_email_segment(body.encode('utf-8'), parsed.get_content_charset(),
parsed.get('Content-Transfer-Encoding'))
body = unescape_and_decode_quoted_printable(body)
body = unescape_and_decode_base64(body)
if parsed.get('Content-Transfer-Encoding') == 'quoted-printable':
body = unescape_simplified_quoted_printable(body)
log.debug(body)
return parsed, body, attachments
@ -212,10 +182,8 @@ def receive_email(envelope, log=None):
header_from = parsed.get('From')
header_to = parsed.get('To')
header_in_reply_to = ascii_strip(parsed.get('In-Reply-To'))
header_message_id = ascii_strip(parsed.get('Message-ID'))
maybe_spam = parsed.get('X-Spam')
suspected_spam = (maybe_spam and maybe_spam.lower() == 'yes')
header_in_reply_to = parsed.get('In-Reply-To')
header_message_id = parsed.get('Message-ID')
if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "":
log.warning("Ignoring mailer daemon")
@ -223,28 +191,25 @@ def receive_email(envelope, log=None):
if Email.objects.filter(reference=header_message_id).exists(): # break before issue thread is created
log.warning("Email already exists")
raise SpecialMailException("Email already exists")
raise Exception("Email already exists")
recipient = envelope.rcpt_tos[0].lower() if envelope.rcpt_tos else header_to.lower()
sender = envelope.mail_from if envelope.mail_from else header_from
subject = ascii_strip(parsed.get('Subject'))
subject = parsed.get('Subject')
if not subject:
subject = "No subject"
subject = decode_inline_encodings(subject)
recipient = decode_inline_encodings(recipient)
sender = decode_inline_encodings(sender)
subject = unescape_and_decode_quoted_printable(subject)
subject = unescape_and_decode_base64(subject)
target_event = find_target_event(recipient)
active_issue_thread, new = find_active_issue_thread(
header_in_reply_to, recipient, subject, target_event, suspected_spam)
active_issue_thread, new = find_active_issue_thread(header_in_reply_to, recipient, subject, target_event)
from hashlib import sha256
random_filename = 'mail-' + sha256(envelope.content).hexdigest()
email = Email.objects.create(
sender=sender, recipient=recipient, body=body, subject=subject, reference=header_message_id,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename),
event=target_event,
in_reply_to=header_in_reply_to, raw_file=ContentFile(envelope.content, name=random_filename), event=target_event,
issue_thread=active_issue_thread)
for attachment in attachments:
email.attachments.add(attachment)
@ -254,7 +219,7 @@ def receive_email(envelope, log=None):
if new:
# auto reply if new issue
references = collect_references(active_issue_thread)
if not sender.startswith('noreply') and not sender.startswith('no-reply') and not suspected_spam:
if not sender.startswith('noreply'):
subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]"
body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels.
@ -303,10 +268,10 @@ class LMTPHandler:
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
reference=email.id)
log.info(f"Created system event {systemevent.id}")
#channel_layer = get_channel_layer()
#await channel_layer.group_send(
# 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
# "message": "email received"})
channel_layer = get_channel_layer()
await channel_layer.group_send(
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"})
log.info(f"Sent message to frontend")
if new and reply:
log.info('Sending message to %s' % reply['To'])

View file

@ -142,7 +142,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test ä', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue(Email.objects.all()[0].raw_file.path)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_quoted_printable_2(self):
from aiosmtpd.smtp import Envelope
@ -163,9 +163,9 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('suche_Mütze', Email.objects.all()[0].subject)
self.assertEqual('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue(Email.objects.all()[0].raw_file.path)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_base64_inline(self):
def test_handle_base64(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
@ -184,36 +184,7 @@ class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('Text mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue(Email.objects.all()[0].raw_file.path)
def test_handle_base64_transfer_encoding(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
envelope.content = b'''Subject: test
From: test3@test
To: test4@test
Message-ID: <1@test>
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64
VGVzdCBtaXQgQmFzZTY0LUtvZGllcnVuZzogw6TDtsO8w58='''
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual(result, '250 Message accepted for delivery')
self.assertEqual(len(Email.objects.all()), 2)
self.assertEqual(len(IssueThread.objects.all()), 1)
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('Test mit Base64-Kodierung: äöüß', Email.objects.all()[0].body)
self.assertTrue(Email.objects.all()[0].raw_file.path)
self.assertTrue( Email.objects.all()[0].raw_file.path)
def test_handle_client_reply(self):
issue_thread = IssueThread.objects.create(
@ -261,7 +232,7 @@ VGVzdCBtaXQgQmFzZTY0LUtvZGllcnVuZzogw6TDtsO8w58='''
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_new')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue(Email.objects.all()[2].raw_file.path)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_handle_client_reply_2(self):
issue_thread = IssueThread.objects.create(
@ -314,7 +285,7 @@ VGVzdCBtaXQgQmFzZTY0LUtvZGllcnVuZzogw6TDtsO8w58='''
self.assertEqual(IssueThread.objects.all()[0].name, 'test')
self.assertEqual(IssueThread.objects.all()[0].state, 'pending_open')
self.assertEqual(IssueThread.objects.all()[0].assigned_to, None)
self.assertTrue(Email.objects.all()[2].raw_file.path)
self.assertTrue( Email.objects.all()[2].raw_file.path)
def test_mail_reply(self):
issue_thread = IssueThread.objects.create(
@ -812,44 +783,6 @@ dGVzdGltYWdl
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
aiosmtplib.send.assert_called_once()
def test_mail_spam_header(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
envelope.content = b'''Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
X-Spam: Yes
test'''
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual(result, '250 Message accepted for delivery')
self.assertEqual(len(Email.objects.all()), 1) # do not send auto reply if spam is suspected
self.assertEqual(len(IssueThread.objects.all()), 1)
aiosmtplib.send.assert_not_called()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('test1@test', Email.objects.all()[0].sender)
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
self.assertEqual('test', Email.objects.all()[0].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[0].issue_thread)
self.assertEqual('<1@test>', Email.objects.all()[0].reference)
self.assertEqual(None, Email.objects.all()[0].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_suspected_spam', IssueThread.objects.all()[0].state)
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_suspected_spam', states[0].state)
def test_mail_4byte_unicode_emoji(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
@ -954,59 +887,6 @@ hello \xe4\xf6\xfc'''
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)
def test_mail_windows_1252(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
envelope.content = b'''Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: text/html; charset=windows-1252
Content-Transfer-Encoding: quoted-printable
=0D=0Ahello='''
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual('250 Message accepted for delivery', result)
self.assertEqual(2, len(Email.objects.all()))
self.assertEqual(1, len(IssueThread.objects.all()))
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('test1@test', Email.objects.all()[0].sender)
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
self.assertEqual('\r\nhello', Email.objects.all()[0].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[0].issue_thread)
self.assertEqual('<1@test>', Email.objects.all()[0].reference)
self.assertEqual(None, Email.objects.all()[0].in_reply_to)
self.assertEqual(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].subject)
self.assertEqual('test2@test', Email.objects.all()[1].sender)
self.assertEqual('test1@test', Email.objects.all()[1].recipient)
self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread)
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual("<1@test>", Email.objects.all()[1].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', IssueThread.objects.all()[0].state)
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)
def test_mail_quoted_printable_transfer_encoding(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
@ -1059,146 +939,3 @@ hello =C3=A4=C3=B6=C3=BC'''
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)
def test_text_with_attachment2(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
envelope.content = b'''Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: multipart/mixed; boundary="abc"
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
--abc
Content-Type: text/plain; charset=utf-8
Content-Disposition: inline
Content-Transfer-Encoding: 8bit
test1
--abc
Content-Type: image/jpeg; name="test.jpg"
Content-Disposition: attachment; filename="test.jpg"
Content-Transfer-Encoding: base64
Content-ID: <1>
X-Attachment-Id: 1
dGVzdGltYWdl
--abc--'''
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual(result, '250 Message accepted for delivery')
self.assertEqual(len(Email.objects.all()), 2)
self.assertEqual(len(IssueThread.objects.all()), 1)
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('test1@test', Email.objects.all()[0].sender)
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
self.assertEqual('test1\n', Email.objects.all()[0].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[0].issue_thread)
self.assertEqual('<1@test>', Email.objects.all()[0].reference)
self.assertEqual(None, Email.objects.all()[0].in_reply_to)
self.assertEqual(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].subject)
self.assertEqual('test2@test', Email.objects.all()[1].sender)
self.assertEqual('test1@test', Email.objects.all()[1].recipient)
self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread)
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual("<1@test>", Email.objects.all()[1].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', IssueThread.objects.all()[0].state)
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)
self.assertEqual(1, len(EmailAttachment.objects.all()))
self.assertEqual(1, EmailAttachment.objects.all()[0].id)
self.assertEqual('image/jpeg', EmailAttachment.objects.all()[0].mime_type)
self.assertEqual('test.jpg', EmailAttachment.objects.all()[0].name)
file_content = EmailAttachment.objects.all()[0].file.read()
self.assertEqual(b'testimage', file_content)
def test_text_non_utf8_in_multipart(self):
from aiosmtpd.smtp import Envelope
from asgiref.sync import async_to_sync
import aiosmtplib
aiosmtplib.send = make_mocked_coro()
handler = LMTPHandler()
server = mock.Mock()
session = mock.Mock()
envelope = Envelope()
envelope.mail_from = 'test1@test'
envelope.rcpt_tos = ['test2@test']
envelope.content = b'''Subject: test
From: test1@test
To: test2@test
Message-ID: <1@test>
Content-Type: multipart/alternative; boundary="abc"
--abc
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit
test1
--abc
Content-Type: text/plain; charset=iso-8859-1
Content-Transfer-Encoding: quoted-printable
hello =E4
--abc
Content-Type: text/plain; charset=windows-1252
Content-Transfer-Encoding: quoted-printable
=0D=0Ahello
--abc--'''
result = async_to_sync(handler.handle_DATA)(server, session, envelope)
self.assertEqual(result, '250 Message accepted for delivery')
self.assertEqual(len(Email.objects.all()), 2)
self.assertEqual(len(IssueThread.objects.all()), 1)
aiosmtplib.send.assert_called_once()
self.assertEqual('test', Email.objects.all()[0].subject)
self.assertEqual('test1@test', Email.objects.all()[0].sender)
self.assertEqual('test2@test', Email.objects.all()[0].recipient)
self.assertEqual('test1\nhello ä\n\r\nhello\n', Email.objects.all()[0].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[0].issue_thread)
self.assertEqual('<1@test>', Email.objects.all()[0].reference)
self.assertEqual(None, Email.objects.all()[0].in_reply_to)
self.assertEqual(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].subject)
self.assertEqual('test2@test', Email.objects.all()[1].sender)
self.assertEqual('test1@test', Email.objects.all()[1].recipient)
self.assertEqual(expected_auto_reply.format(IssueThread.objects.all()[0].short_uuid()),
Email.objects.all()[1].body)
self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread)
self.assertTrue(Email.objects.all()[1].reference.startswith("<"))
self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>"))
self.assertEqual("<1@test>", Email.objects.all()[1].in_reply_to)
self.assertEqual('test', IssueThread.objects.all()[0].name)
self.assertEqual('pending_new', IssueThread.objects.all()[0].state)
self.assertEqual(None, IssueThread.objects.all()[0].assigned_to)
states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0])
self.assertEqual(1, len(states))
self.assertEqual('pending_new', states[0].state)

View file

@ -13,7 +13,7 @@ Automat==22.10.0
beautifulsoup4==4.12.2
bs4==0.0.1
certifi==2023.11.17
#cffi==1.16.0
cffi==1.16.0
channels==4.0.0
channels-redis==4.1.0
charset-normalizer==3.3.2
@ -40,12 +40,12 @@ inflection==0.5.1
itypes==1.2.0
Jinja2==3.1.2
MarkupSafe==2.1.3
#msgpack==1.0.7
#msgpack-python==0.5.6
msgpack==1.0.7
msgpack-python==0.5.6
multidict==6.0.5
openapi-codec==1.3.2
packaging==23.2
Pillow==11.1.0
Pillow==10.1.0
pyasn1==0.5.1
pyasn1-modules==0.3.0
pycares==4.4.0
@ -69,6 +69,7 @@ typing_extensions==4.8.0
uritemplate==4.1.1
urllib3==2.1.0
uvicorn==0.24.0.post1
watchfiles==0.21.0
websockets==12.0
yarl==1.9.4
zope.interface==6.1

View file

@ -102,6 +102,12 @@ def manual_ticket(request, event_slug):
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)
@ -127,75 +133,24 @@ def add_comment(request, pk):
issue_thread=issue,
comment=request.data['comment'],
)
systemevent = SystemEvent.objects.create(type='comment added', reference=comment.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": "comment added"}
)
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
def filter_issues(issues, query):
query_tokens = query.lower().split(' ')
matches = []
query_tokens = query.split(' ')
for issue in issues:
value = 0
if "T#" + issue.short_uuid() in query:
value += 12
matches.append(
{'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()} and matched "T#{issue.short_uuid()}"'})
elif "#" + issue.short_uuid() in query:
value += 11
matches.append(
{'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()} and matched "#{issue.short_uuid()}"'})
elif issue.short_uuid() in query:
value += 10
matches.append({'type': 'ticket_uuid', 'text': f'is exactly {issue.short_uuid()}'})
if "T#" + str(issue.id) in query:
value += 10
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "T#{issue.id}"'})
elif "#" + str(issue.id) in query:
value += 7
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id} and matched "#{issue.id}"'})
elif str(issue.id) in query:
value += 4
matches.append({'type': 'ticket_id', 'text': f'is exactly {issue.id}'})
for item in issue.related_items:
if "I#" + str(item.id) in query:
value += 8
matches.append({'type': 'item_id', 'text': f'is exactly {item.id} and matched "I#{item.id}"'})
elif "#" + str(item.id) in query:
value += 5
matches.append({'type': 'item_id', 'text': f'is exactly {item.id} and matched "#{item.id}"'})
elif str(item.id) in query:
value += 3
matches.append({'type': 'item_id', 'text': f'is exactly {item.id}'})
for token in query_tokens:
if token in item.description.lower():
value += 1
matches.append({'type': 'item_description', 'text': f'contains {token}'})
for comment in item.comments.all():
for token in query_tokens:
if token in comment.comment.lower():
value += 1
matches.append({'type': 'item_comment', 'text': f'contains {token}'})
for token in query_tokens:
if token in issue.name.lower():
if token in issue.description:
value += 1
matches.append({'type': 'ticket_name', 'text': f'contains {token}'})
for comment in issue.comments.all():
for token in query_tokens:
if token in comment.comment.lower():
value += 1
matches.append({'type': 'ticket_comment', 'text': f'contains {token}'})
for email in issue.emails.all():
for token in query_tokens:
if token in email.subject.lower():
value += 1
matches.append({'type': 'email_subject', 'text': f'contains {token}'})
if token in email.body.lower():
value += 1
matches.append({'type': 'email_body', 'text': f'contains {token}'})
if token in email.sender.lower():
value += 1
matches.append({'type': 'email_sender', 'text': f'contains {token}'})
if value > 0:
yield {'search_score': value, 'issue': issue, 'search_matches': matches}
yield {'search_score': value, 'issue': issue}
@api_view(['GET'])
@ -205,10 +160,7 @@ def search_issues(request, event_slug, query):
event = Event.objects.get(slug=event_slug)
if not request.user.has_event_perm(event, 'view_issuethread'):
return Response(status=403)
serializer = IssueSerializer()
queryset = IssueThread.objects.filter(event=event)
items = filter_issues(queryset.prefetch_related(*serializer.Meta.prefetch_related_fields),
b64decode(query).decode('utf-8'))
items = filter_issues(IssueThread.objects.filter(event=event), b64decode(query).decode('utf-8'))
return Response(SearchResultSerializer(items, many=True).data)
except Event.DoesNotExist:
return Response(status=404)

View file

@ -1,18 +0,0 @@
# Generated by Django 4.2.7 on 2025-03-15 21:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0012_remove_issuethread_related_items_and_more'),
]
operations = [
migrations.AlterField(
model_name='statechange',
name='state',
field=models.CharField(choices=[('pending_new', 'New'), ('pending_open', 'Open'), ('pending_shipping', 'Needs to be shipped'), ('pending_physical_confirmation', 'Needs to be confirmed physically'), ('pending_return', 'Needs to be returned'), ('pending_postponed', 'Postponed'), ('pending_suspected_spam', 'Suspected Spam'), ('waiting_details', 'Waiting for details'), ('waiting_pre_shipping', 'Waiting for Address/Shipping Info'), ('closed_returned', 'Closed: Returned'), ('closed_shipped', 'Closed: Shipped'), ('closed_not_found', 'Closed: Not found'), ('closed_not_our_problem', 'Closed: Not our problem'), ('closed_duplicate', 'Closed: Duplicate'), ('closed_timeout', 'Closed: Timeout'), ('closed_spam', 'Closed: Spam'), ('closed_nothing_missing', 'Closed: Nothing missing'), ('closed_wtf', 'Closed: WTF'), ('found_open', 'Item Found and stored externally'), ('found_closed', 'Item Found and stored externally and closed')], default='pending_new', max_length=255),
),
]

View file

@ -16,7 +16,6 @@ STATE_CHOICES = (
('pending_physical_confirmation', 'Needs to be confirmed physically'),
('pending_return', 'Needs to be returned'),
('pending_postponed', 'Postponed'),
('pending_suspected_spam', 'Suspected Spam'),
('waiting_details', 'Waiting for details'),
('waiting_pre_shipping', 'Waiting for Address/Shipping Info'),
('closed_returned', 'Closed: Returned'),
@ -47,11 +46,6 @@ class IssueThread(SoftDeleteModel):
event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads')
manually_created = models.BooleanField(default=False)
def __init__(self, *args, **kwargs):
if 'initial_state' in kwargs:
self._initial_state = kwargs.pop('initial_state')
super().__init__(*args, **kwargs)
def short_uuid(self):
return self.uuid[:8]
@ -116,9 +110,8 @@ def set_uuid(sender, instance, **kwargs):
@receiver(post_save, sender=IssueThread)
def create_issue_thread(sender, instance, created, **kwargs):
if created and instance.state_changes.count() == 0:
initial_state = getattr(instance, '_initial_state', None)
StateChange.objects.create(issue_thread=instance, state=initial_state if initial_state else 'pending_new')
if created:
StateChange.objects.create(issue_thread=instance, state='pending_new')
class Comment(models.Model):

View file

@ -139,12 +139,10 @@ class IssueSerializer(BasicIssueSerializer):
class SearchResultSerializer(serializers.Serializer):
search_score = serializers.IntegerField()
search_matches = serializers.ListField(child=serializers.DictField())
issue = IssueSerializer()
item = IssueSerializer()
def to_representation(self, instance):
return {**IssueSerializer(instance['issue']).data, 'search_score': instance['search_score'],
'search_matches': instance['search_matches']}
return {**IssueSerializer(instance['item']).data, 'search_score': instance['search_score']}
class Meta:
model = IssueThread

View file

@ -9,7 +9,6 @@ class RelationSerializer(serializers.ModelSerializer):
class Meta:
model = ItemRelation
fields = ('id', 'status', 'timestamp', 'item', 'issue_thread')
read_only_fields = ('id', 'timestamp')
class BasicIssueSerializer(serializers.ModelSerializer):

View file

@ -4,7 +4,6 @@ from django.test import TestCase, Client
from authentication.models import ExtendedUser
from inventory.models import Event, Container, Item
from inventory.models import Comment as ItemComment
from mail.models import Email, EmailAttachment
from tickets.models import IssueThread, StateChange, Comment, ItemRelation, Assignment
from django.contrib.auth.models import Permission
@ -384,108 +383,15 @@ class IssueSearchTest(TestCase):
def setUp(self):
super().setUp()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save()
self.event = Event.objects.create(slug='EVENT', name='Event')
self.box = Container.objects.create(name='box1')
self.item = Item.objects.create(container=self.box, description="foo", event=self.event)
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_search_empty_result(self):
def test_search(self):
search_query = b64encode(b'abc').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual([], response.json())
def test_search(self):
now = datetime.now()
issue = IssueThread.objects.create(
name="test issue Abc",
event=self.event,
)
mail1 = Email.objects.create(
subject='test',
body='test aBc',
sender='bar@test',
recipient='2@test',
issue_thread=issue,
timestamp=now,
)
mail2 = Email.objects.create(
subject='Re: test',
body='test',
sender='2@test',
recipient='1@test',
issue_thread=issue,
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 deF",
timestamp=now + timedelta(seconds=4),
)
match = ItemRelation.objects.create(
issue_thread=issue,
item=self.item,
timestamp=now + timedelta(seconds=5),
)
item_comment = ItemComment.objects.create(
item=self.item,
comment="baz",
timestamp=now + timedelta(seconds=6),
)
search_query = b64encode(b'abC').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
score2 = response.json()[0]['search_score']
search_query = b64encode(b'dEf').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
score1 = response.json()[0]['search_score']
search_query = b64encode(b'ghi').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(0, len(response.json()))
search_query = b64encode(b'Abc def').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
score3 = response.json()[0]['search_score']
self.assertGreater(score3, score2)
self.assertGreater(score2, score1)
self.assertGreater(score1, 0)
search_query = b64encode(b'foo').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
search_query = b64encode(b'bar').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])
search_query = b64encode(b'baz').decode('utf-8')
response = self.client.get(f'/api/2/{self.event.slug}/tickets/{search_query}/')
self.assertEqual(200, response.status_code)
self.assertEqual(1, len(response.json()))
self.assertEqual(issue.id, response.json()[0]['id'])

View file

@ -345,13 +345,6 @@
notify:
- restart postfix
- name: configure rspamd dkim
template:
src: templates/rspamd-dkim.cf.j2
dest: /etc/rspamd/local.d/dkim_signing.conf
notify:
- restart rspamd
- name: configure rspamd
copy:
content: |

View file

@ -32,11 +32,12 @@ smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = polaris.lab.or.it
myhostname = polaris.c3lf.de
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
myorigin = /etc/mailname
mydestination = $myhostname, , localhost
relayhost = firefly.lab.or.it
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +

View file

@ -1,79 +0,0 @@
# local.d/dkim_signing.conf
enabled = true;
# If false, messages with empty envelope from are not signed
allow_envfrom_empty = true;
# If true, envelope/header domain mismatch is ignored
allow_hdrfrom_mismatch = false;
# If true, multiple from headers are allowed (but only first is used)
allow_hdrfrom_multiple = false;
# If true, username does not need to contain matching domain
allow_username_mismatch = false;
# Default path to key, can include '$domain' and '$selector' variables
path = "/var/lib/rspamd/dkim/$domain.$selector.key";
# Default selector to use
selector = "dkim";
# If false, messages from authenticated users are not selected for signing
sign_authenticated = true;
# If false, messages from local networks are not selected for signing
sign_local = true;
# Map file of IP addresses/subnets to consider for signing
# sign_networks = "/some/file"; # or url
# Symbol to add when message is signed
symbol = "DKIM_SIGNED";
# Whether to fallback to global config
try_fallback = true;
# Domain to use for DKIM signing: can be "header" (MIME From), "envelope" (SMTP From), "recipient" (SMTP To), "auth" (SMTP username) or directly specified domain name
use_domain = "header";
# Domain to use for DKIM signing when sender is in sign_networks ("header"/"envelope"/"auth")
#use_domain_sign_networks = "header";
# Domain to use for DKIM signing when sender is a local IP ("header"/"envelope"/"auth")
#use_domain_sign_local = "header";
# Whether to normalise domains to eSLD
use_esld = true;
# Whether to get keys from Redis
use_redis = false;
# Hash for DKIM keys in Redis
key_prefix = "DKIM_KEYS";
# map of domains -> names of selectors (since rspamd 1.5.3)
#selector_map = "/etc/rspamd/dkim_selectors.map";
# map of domains -> paths to keys (since rspamd 1.5.3)
#path_map = "/etc/rspamd/dkim_paths.map";
# If `true` get pubkey from DNS record and check if it matches private key
check_pubkey = false;
# Set to `false` if you want to skip signing if public and private keys mismatch
allow_pubkey_mismatch = true;
# Domain specific settings
domain {
# Domain name is used as key
c3lf.de {
# Private key path
path = "/var/lib/rspamd/dkim/{{ mail_domain }}.key";
# Selector
selector = "{{ mail_domain }}";
}
}

View file

@ -1,8 +1,13 @@
FROM python:3.11-slim-bookworm
FROM python:3.11-bookworm
LABEL authors="lagertonne"
ENV PYTHONUNBUFFERED 1
RUN mkdir /code
WORKDIR /code
COPY requirements.dev.txt /code/
RUN pip install -r requirements.dev.txt
COPY requirements.prod.txt /code/
RUN apt update && apt install -y mariadb-client
RUN pip install -r requirements.dev.txt
RUN pip install -r requirements.prod.txt
RUN pip install mysqlclient
COPY .. /code/

View file

@ -1,4 +1,4 @@
FROM node:22-alpine
FROM docker.io/node:22
RUN mkdir /web
WORKDIR /web

View file

@ -1,20 +1,22 @@
name: c3lf-sys3-dev
services:
core:
build:
context: ../../core
dockerfile: ../deploy/dev/Dockerfile.backend
command: bash -c 'python manage.py migrate && python testdata.py && python manage.py runserver 0.0.0.0:8000'
command: bash -c 'python manage.py migrate && python manage.py runserver 0.0.0.0:8000'
environment:
- HTTP_HOST=core
- DB_FILE=.local/dev.db
- DEBUG_MODE_ACTIVE=true
- DB_HOST=db
- DB_PORT=3306
- DB_NAME=system3
- DB_USER=system3
- DB_PASSWORD=system3
volumes:
- ../../core:/code:ro
- ../testdata.py:/code/testdata.py:ro
- backend_context:/code/.local
- ../../core:/code
ports:
- "8000:8000"
depends_on:
- db
frontend:
build:
@ -22,12 +24,25 @@ services:
dockerfile: ../deploy/dev/Dockerfile.frontend
command: npm run serve
volumes:
- ../../web/src:/web/src
- ../../web:/web:ro
- /web/node_modules
- ./vue.config.js:/web/vue.config.js
ports:
- "8080:8080"
depends_on:
- core
db:
image: mariadb
environment:
MARIADB_RANDOM_ROOT_PASSWORD: true
MARIADB_DATABASE: system3
MARIADB_USER: system3
MARIADB_PASSWORD: system3
volumes:
- mariadb_data:/var/lib/mysql
ports:
- "3306:3306"
volumes:
backend_context:
mariadb_data:

View file

@ -1,88 +0,0 @@
import os
def setup():
from authentication.models import ExtendedUser, EventPermission
from inventory.models import Event
from django.contrib.auth.models import Permission, Group
permissions = ['add_item', 'view_item', 'view_file', 'delete_item', 'change_item']
if not ExtendedUser.objects.filter(username='admin').exists():
admin = ExtendedUser.objects.create_superuser('admin', 'admin@example.com', 'admin')
admin.set_password('admin')
admin.user_permissions.add(*Permission.objects.all())
admin.save()
if not ExtendedUser.objects.filter(username='testuser').exists():
testuser = ExtendedUser.objects.create_user('testuser', 'testuser@example.com', 'testuser')
testuser.set_password('testuser')
testuser.user_permissions.add(*Permission.objects.all())
testuser.save()
team = Group.objects.get(name='Team')
team.permissions.add(
*Permission.objects.all()
)
if not ExtendedUser.objects.filter(username='testuser2').exists():
testuser2 = ExtendedUser.objects.create_user('testuser2', 'testuser2@example.com', 'testuser2')
testuser2.set_password('testuser2')
testuser2.groups.add(team)
testuser2.save()
event1 = Event.objects.get_or_create(id=1, name='first test event', slug='TEST1',
start='2023-12-18 00:00:00.000000', end='2023-12-27 00:00:00.000000',
pre_start='2023-12-31 00:00:00.000000', post_end='2024-01-04 00:00:00.000000')[
0]
event2 = Event.objects.get_or_create(id=2, name='second test event', slug='TEST2',
start='2024-12-18 00:00:00.000000', end='2024-12-27 00:00:00.000000',
pre_start='2024-12-31 00:00:00.000000', post_end='2025-01-04 00:00:00.000000')[
0]
# for permission in permissions:
# EventPermission.objects.create(event=event_37c3, user=foo,
# permission=Permission.objects.get(codename=permission))
from tickets.models import IssueThread
from mail.models import Email
issue_thread = IssueThread.objects.get_or_create(
id=1,
name="test",
event=Event.objects.get(slug='TEST1')
)[0]
mail1 = Email.objects.get_or_create(
id=1,
subject='test subject',
body='test',
sender='test1@test',
recipient='test2@test',
issue_thread=issue_thread,
)[0]
mail1_reply = Email.objects.get_or_create(
id=2,
subject='Message received',
body='Thank you for your message.',
sender='test2@test',
recipient='test1@test',
in_reply_to=mail1.reference,
issue_thread=issue_thread,
)[0]
def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings")
import django
django.setup()
from django.core.management import call_command
call_command('migrate')
setup()
print('testdata initialised')
if __name__ == '__main__':
main()

View file

@ -1,11 +1,11 @@
FROM python:3.11-slim-bookworm
FROM python:3.11-bookworm
LABEL authors="lagertonne"
ENV PYTHONUNBUFFERED 1
RUN mkdir /code
WORKDIR /code
RUN apt update && apt install -y pkg-config mariadb-client default-libmysqlclient-dev build-essential
RUN pip install mysqlclient
COPY requirements.prod.txt /code/
RUN apt update && apt install -y mariadb-client
RUN pip install -r requirements.prod.txt
RUN pip install mysqlclient
COPY .. /code/

View file

@ -1,4 +1,4 @@
FROM node:22-alpine
FROM docker.io/node:22
RUN mkdir /web
WORKDIR /web

View file

@ -1,4 +1,3 @@
name: c3lf-sys3-testing
services:
redis:
image: redis
@ -21,7 +20,7 @@ services:
build:
context: ../../core
dockerfile: ../deploy/testing/Dockerfile.backend
command: bash -c 'python manage.py migrate && python testdata.py && python /code/server.py'
command: bash -c 'python manage.py migrate && python /code/server.py'
environment:
- HTTP_HOST=core
- REDIS_HOST=redis
@ -30,17 +29,13 @@ services:
- DB_NAME=system3
- DB_USER=system3
- DB_PASSWORD=system3
- MAIL_DOMAIN=mail:1025
volumes:
- ../../core:/code:ro
- ../testdata.py:/code/testdata.py:ro
- backend_context:/code
- ../../core:/code
ports:
- "8000:8000"
depends_on:
- db
- redis
- mail
frontend:
build:
@ -49,28 +44,12 @@ services:
command: npm run serve
volumes:
- ../../web:/web:ro
- ./vue.config.js:/web/vue.config.js:ro
- frontend_context:/web
- /web/node_modules
- ./vue.config.js:/web/vue.config.js
ports:
- "8080:8080"
depends_on:
- core
mail:
image: docker.io/axllent/mailpit
volumes:
- mailpit_data:/data
ports:
- 8025:8025
- 1025:1025
environment:
MP_MAX_MESSAGES: 5000
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
mariadb_data:
mailpit_data:
frontend_context:
backend_context:
mariadb_data:

0
web/node_modules/.forgit_fordocker generated vendored
View file

9399
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,29 +2,7 @@
<div>
<Modal v-if="isModal" title="Add Item" @close="$emit('close')">
<template #body>
<div>
<InputPhoto
:model="item"
field="file"
:on-capture="storeImage"
/>
<InputString
label="description"
:model="item"
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>
</div>
<EditItem :item="item"/>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
@ -35,40 +13,33 @@
</template>
<script>
import {mapActions, mapGetters, mapState} from "vuex";
import Modal from '@/components/Modal';
import InputCombo from "@/components/inputs/InputCombo.vue";
import InputPhoto from "@/components/inputs/InputPhoto.vue";
import InputString from "@/components/inputs/InputString.vue";
import EditItem from '@/components/EditItem';
import {mapActions, mapState} from "vuex";
export default {
name: 'AddItemModal',
components: {InputString, InputPhoto, InputCombo, Modal},
components: {Modal, EditItem},
props: ['isModal'],
data: () => ({
item: {}
}),
computed: {
...mapState(['lastUsed']),
...mapGetters(['getBoxes']),
boxes({getBoxes}) {
return getBoxes.map(obj => ({cid: obj.id, box: obj.name}));
}
...mapState(['lastUsed'])
},
methods: {
...mapActions(['postItem', 'loadBoxes', 'scheduleAfterInit']),
async saveNewItem() {
await this.postItem(this.item);
this.$emit('close');
},
storeImage(image) {
this.item.dataImage = image;
saveNewItem() {
this.postItem(this.item).then(() => {
this.$emit('close');
});
}
},
created() {
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
},
mounted() {
this.scheduleAfterInit(() => [this.loadBoxes().then(() => {
this.item = {box: this.lastUsed.box || '', cid: this.lastUsed.cid || ''};
})])
this.scheduleAfterInit(() => [this.loadBoxes()]);
}
};
</script>

View file

@ -19,10 +19,11 @@
<script>
import {mapActions} from 'vuex';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
export default {
name: 'AddTicketModal',
components: {Modal},
components: {Modal, EditItem},
props: ['isModal'],
data: () => ({
ticket: {

View file

@ -0,0 +1,50 @@
<template>
<div>
<h6>Editing Item <span class="badge badge-secondary">#{{ item.uid }}</span></h6>
<InputPhoto
:model="item"
field="file"
:on-capture="storeImage"
/>
<InputString
label="description"
:model="item"
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>
</div>
</template>
<script>
import InputString from './inputs/InputString';
import InputCombo from './inputs/InputCombo';
import {mapGetters} from 'vuex';
import InputPhoto from './inputs/InputPhoto';
export default {
name: 'EditItem',
components: {InputPhoto, InputCombo, InputString},
props: ['item'],
computed: {
...mapGetters(['getBoxes']),
boxes({getBoxes}) {
return getBoxes.map(obj => ({cid: obj.cid, box: obj.name}));
}
},
methods: {
storeImage(image) {
this.item.dataImage = image;
}
}
};
</script>

View file

@ -115,10 +115,10 @@ export default {
this.$router.push(link);
},
isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item' || this.getActiveView === 'item_search';
return this.getActiveView === 'items' || this.getActiveView === 'item';
},
isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket' || this.getActiveView === 'ticket_search';
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
},
setLayout(layout) {
if (this.route.query.layout === layout)

View file

@ -24,15 +24,6 @@
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'placement'">
<font-awesome-icon icon="archive"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'created'">
<font-awesome-icon icon="archive"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'returned'">
<font-awesome-icon icon="archive"/>
</span>
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'deleted'">
<font-awesome-icon icon="trash"/>
</span>
<span class="timeline-item-icon faded-icon" v-else>
<font-awesome-icon icon="pen"/>
</span>
@ -44,9 +35,6 @@
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
<TimelinePlacement v-else-if="item.type === 'placement'" :item="item"/>
<TimelineRelatedTicket v-else-if="item.type === 'issue_relation'" :item="item"/>
<TimelineCreated v-else-if="item.type === 'created'" :item="item"/>
<TimelineReturned v-else-if="item.type === 'returned'" :item="item"/>
<TimelineDeleted v-else-if="item.type === 'deleted'" :item="item"/>
<p v-else>{{ item }}</p>
</li>
<li class="timeline-item">
@ -70,16 +58,10 @@ import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
import TimelinePlacement from "@/components/TimelinePlacement.vue";
import TimelineRelatedTicket from "@/components/TimelineRelatedTicket.vue";
import TimelineCreated from "@/components/TimelineCreated.vue";
import TimelineReturned from "@/components/TimelineReturned.vue";
import TimelineDeleted from "@/components/TimelineDeleted.vue";
export default {
name: 'Timeline',
components: {
TimelineDeleted,
TimelineReturned,
TimelineCreated,
TimelineRelatedTicket,
TimelinePlacement,
TimelineShippingVoucher,
@ -221,4 +203,4 @@ a {
}
</style>
</style>

View file

@ -1,83 +0,0 @@
<template>
<div class="timeline-item-description"><span>created by
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<a href="#">$USER</a> at <time :datetime="timestamp">{{ timestamp }}</time></span>
</div>
</template>
<script>
import {mapState} from "vuex";
export default {
name: 'TimelineCreated',
props: {
'item': {
type: Object,
required: true
}
},
computed: {
'timestamp': function () {
return new Date(this.item.timestamp).toLocaleString();
},
}
};
</script>
<style scoped>
a {
color: inherit;
}
.timeline-item-description {
display: flex;
padding-top: 6px;
gap: 8px;
color: var(--gray);
img {
flex-shrink: 0;
}
a {
/*color: var(--c-grey-500);*/
font-weight: 500;
text-decoration: none;
&:hover,
&:focus {
outline: 0; /* Don't actually do this */
color: var(--info);
}
}
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
aspect-ratio: 1 / 1;
flex-shrink: 0;
width: 40px;
height: 40px;
&.small {
width: 28px;
height: 28px;
}
img {
object-fit: cover;
}
}
</style>

View file

@ -1,83 +0,0 @@
<template>
<div class="timeline-item-description"><span>marked deleted by
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<a href="#">$USER</a> at <time :datetime="timestamp">{{ timestamp }}</time></span>
</div>
</template>
<script>
import {mapState} from "vuex";
export default {
name: 'TimelineDeleted',
props: {
'item': {
type: Object,
required: true
}
},
computed: {
'timestamp': function () {
return new Date(this.item.timestamp).toLocaleString();
},
}
};
</script>
<style scoped>
a {
color: inherit;
}
.timeline-item-description {
display: flex;
padding-top: 6px;
gap: 8px;
color: var(--gray);
img {
flex-shrink: 0;
}
a {
/*color: var(--c-grey-500);*/
font-weight: 500;
text-decoration: none;
&:hover,
&:focus {
outline: 0; /* Don't actually do this */
color: var(--info);
}
}
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
aspect-ratio: 1 / 1;
flex-shrink: 0;
width: 40px;
height: 40px;
&.small {
width: 28px;
height: 28px;
}
img {
object-fit: cover;
}
}
</style>

View file

@ -1,83 +0,0 @@
<template>
<div class="timeline-item-description"><span>marked returned by
<i class="avatar | small">
<font-awesome-icon icon="user"/>
</i>
<a href="#">$USER</a> at <time :datetime="timestamp">{{ timestamp }}</time></span>
</div>
</template>
<script>
import {mapState} from "vuex";
export default {
name: 'TimelineReturned',
props: {
'item': {
type: Object,
required: true
}
},
computed: {
'timestamp': function () {
return new Date(this.item.timestamp).toLocaleString();
},
}
};
</script>
<style scoped>
a {
color: inherit;
}
.timeline-item-description {
display: flex;
padding-top: 6px;
gap: 8px;
color: var(--gray);
img {
flex-shrink: 0;
}
a {
/*color: var(--c-grey-500);*/
font-weight: 500;
text-decoration: none;
&:hover,
&:focus {
outline: 0; /* Don't actually do this */
color: var(--info);
}
}
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden;
aspect-ratio: 1 / 1;
flex-shrink: 0;
width: 40px;
height: 40px;
&.small {
width: 28px;
height: 28px;
}
img {
object-fit: cover;
}
}
</style>

View file

@ -1,9 +1,9 @@
<template>
<button @click.stop="handleClick" :disabled="disabled || inProgress">
<button @click.stop="handleClick" :disabled="disabled">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
:class="{'d-none': !inProgress}"></span>
<span class="ml-2" :class="{'d-none': !inProgress}">In Progress...</span>
<span :class="{'d-none': inProgress}"><slot></slot></span>
:class="{'d-none': !disabled}"></span>
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span>
<span :class="{'d-none': disabled}"><slot></slot></span>
</button>
</template>
@ -13,7 +13,7 @@ export default {
name: 'AsyncButton',
data() {
return {
inProgress: false,
disabled: false,
};
},
props: {
@ -21,21 +21,17 @@ export default {
type: Function,
required: true,
},
disabled: {
type: Boolean,
required: false,
},
},
methods: {
async handleClick() {
if (this.task && typeof this.task === 'function') {
this.inProgress = true;
this.disabled = true;
try {
await this.task();
} catch (e) {
console.error(e);
} finally {
this.inProgress = false;
this.disabled = false;
}
}
},
@ -47,4 +43,4 @@ export default {
.spinner-border {
vertical-align: -0.125em;
}
</style>
</style>

View file

@ -43,11 +43,11 @@ export default {
props: ['label', 'model', 'nameKey', 'uniqueKey', 'options', 'onOptionAdd'],
data: ({options, model, nameKey, uniqueKey}) => ({
internalName: model[nameKey],
selectedOption: options.filter(e => e[uniqueKey] === model[uniqueKey])[0],
selectedOption: options.filter(e => e[uniqueKey] == model[uniqueKey])[0],
addingOption: false
}),
computed: {
isValid: ({options, nameKey, internalName}) => options.some(e => e[nameKey] === internalName),
isValid: ({options, nameKey, internalName}) => options.some(e => e[nameKey] == internalName),
sortedOptions: ({
options,
nameKey
@ -56,7 +56,7 @@ export default {
watch: {
internalName(newValue) {
if (this.isValid) {
if (!this.selectedOption || newValue !== this.selectedOption[this.nameKey]) {
if (!this.selectedOption || newValue != this.selectedOption[this.nameKey]) {
this.selectedOption = this.options.filter(e => e[this.nameKey] === newValue)[0];
}
this.model[this.nameKey] = this.selectedOption[this.nameKey];

View file

@ -12,7 +12,6 @@
<script>
import {mapActions, mapGetters} from "vuex";
import router from "@/router";
export default {
name: 'SearchBox',
@ -22,34 +21,21 @@ export default {
}
},
computed: {
...mapGetters(['getActiveView', 'route']),
},
watch: {
route() {
this.search_query = this.route.params.search || '';
}
...mapGetters(['getActiveView'])
},
methods: {
...mapActions(['searchEventItems', 'searchEventTickets']),
isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item' || this.getActiveView === 'item_search';
return this.getActiveView === 'items' || this.getActiveView === 'item';
},
isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket' || this.getActiveView === 'ticket_search';
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
},
dispatchSearch() {
if (this.isItemView()) {
router.push({
name: "item_search",
query: this.route.query,
params: {...this.route.params, search: this.search_query}
});
this.searchEventItems(this.search_query);
} else if (this.isTicketView()) {
router.push({
name: "ticket_search",
query: this.route.query,
params: {...this.route.params, search: this.search_query}
});
this.searchEventTickets(this.search_query);
}
}
}

View file

@ -20,9 +20,6 @@ export default (config) => (store) => {
}
});
store.state[config.isLoadedKey] = true;
if ('validate' in config) {
config.validate(store.state);
}
}
const reload = initialize;

View file

@ -2,7 +2,6 @@ import {createRouter, createWebHistory} from 'vue-router'
import store from '@/store';
import Item from "@/views/Item.vue";
import ItemSearch from "@/views/ItemSearch.vue";
import Items from '@/views/Items';
import Boxes from '@/views/Boxes';
import Files from '@/views/Files';
@ -11,7 +10,6 @@ import Login from '@/views/Login.vue';
import Register from '@/views/Register.vue';
import Dashboard from "@/views/admin/Dashboard.vue";
import Tickets from "@/views/Tickets.vue";
import TicketSearch from "@/views/TicketSearch.vue";
import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue";
import Empty from "@/views/Empty.vue";
@ -29,10 +27,6 @@ const routes = [
path: '/:event/items/', name: 'items', component: Items, meta:
{requiresAuth: true, requiresPermission: 'view_item'}
},
{
path: '/:event/items/:search', name: 'item_search', component: ItemSearch, meta:
{requiresAuth: true, requiresPermission: 'view_item'}
},
{
path: '/:event/item/:id/', name: 'item', component: Item, meta:
{requiresAuth: true, requiresPermission: 'view_item'}
@ -49,10 +43,6 @@ const routes = [
path: '/:event/tickets/', name: 'tickets', component: Tickets, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}
},
{
path: '/:event/tickets/:search', name: 'ticket_search', component: TicketSearch, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}
},
{
path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}

View file

@ -9,7 +9,6 @@ import persistentStatePlugin from "@/persistent-state-plugin";
const store = createStore({
state: {
//shared
keyIncrement: 0,
events: [],
items: [],
@ -23,12 +22,9 @@ const store = createStore({
loadedItems: {},
loadedTickets: {},
loadedItemSearchResults: {},
loadedTicketSearchResults: {},
//local
lastEvent: 'all',
lastUsed: {},
searchQuery: '',
remember: false,
user: {
username: null,
@ -61,6 +57,7 @@ const store = createStore({
'2kg-de': '2kg Paket (DE)',
'5kg-de': '5kg Paket (DE)',
'10kg-de': '10kg Paket (DE)',
'2kg-eu': '2kg Paket (EU)',
'5kg-eu': '5kg Paket (EU)',
'10kg-eu': '10kg Paket (EU)',
}
@ -69,33 +66,12 @@ const store = createStore({
route: state => router.currentRoute.value,
session: state => http_session(state.user.token),
getEventSlug: state => router.currentRoute.value.params.event ? router.currentRoute.value.params.event : state.lastEvent,
searchQuery: state => router.currentRoute.value.params.search,
getAllItems: state => Object.values(state.loadedItems).flat(),
getAllTickets: state => Object.values(state.loadedTickets).flat(),
getEventItems: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllItems : getters.getAllItems.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
getEventTickets: (state, getters) => getters.getEventSlug === 'all' ? getters.getAllTickets : getters.getAllTickets.filter(t => t.event === getters.getEventSlug || (t.event == null && getters.getEventSlug === 'none')),
isItemsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedItems : Object.keys(state.loadedItems).includes(getters.getEventSlug),
isTicketsLoaded: (state, getters) => (getters.getEventSlug === 'all' || getters.getEventSlug === 'none') ? !!state.loadedTickets : Object.keys(state.loadedTickets).includes(getters.getEventSlug),
getItemsSearchResults: (state, getters) => {
if (getters.getEventSlug === 'all') {
return state.events.map(e => {
return state.loadedItemSearchResults[e.slug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
}).flat();
} else {
return state.loadedItemSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
}
},
getTicketsSearchResults: (state, getters) => {
if (getters.getEventSlug === 'all') {
return state.events.map(e => {
return state.loadedTicketSearchResults[e.slug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
}).flat();
} else {
return state.loadedTicketSearchResults[getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))] || []
}
},
isItemsSearchLoaded: (state, getters) => Object.keys(state.loadedItemSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))) || getters.getEventSlug === 'all',
isTicketsSearchLoaded: (state, getters) => Object.keys(state.loadedTicketSearchResults).includes(getters.getEventSlug + '/' + base64.encode(utf8.encode(getters.searchQuery))) || getters.getEventSlug === 'all',
getActiveView: state => router.currentRoute.value.name || 'items',
getFilters: state => router.currentRoute.value.query,
getBoxes: state => state.loadedBoxes,
@ -130,9 +106,9 @@ const store = createStore({
layout: (state, getters) => {
if (router.currentRoute.value.query.layout)
return router.currentRoute.value.query.layout;
if (getters.getActiveView === 'items' || getters.getActiveView === 'item_search')
if (getters.getActiveView === 'items')
return 'cards';
if (getters.getActiveView === 'tickets' || getters.getActiveView === 'ticket_search')
if (getters.getActiveView === 'tickets')
return 'tasks';
},
isLoggedIn(state) {
@ -171,10 +147,6 @@ const store = createStore({
state.loadedItems[slug] = items;
state.loadedItems = {...state.loadedItems};
},
setItemSearchResults(state, {slug, query, items}) {
state.loadedItemSearchResults[slug + '/' + query] = items;
state.loadedItemSearchResults = {...state.loadedItemSearchResults};
},
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;
@ -195,10 +167,6 @@ const store = createStore({
state.loadedTickets[slug] = tickets;
state.loadedTickets = {...state.loadedTickets};
},
setTicketSearchResults(state, {slug, query, items}) {
state.loadedTicketSearchResults[slug + '/' + query] = items;
state.loadedTicketSearchResults = {...state.loadedTicketSearchResults};
},
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;
@ -371,7 +339,7 @@ const store = createStore({
commit('replaceEvents', [...state.events.filter(e => e.id !== event_id)])
}
},
async updateEvent({commit, dispatch, state}, {id, partial_event}) {
async updateEvent({commit, dispatch, state}, {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.id !== id), data])
@ -394,39 +362,25 @@ const store = createStore({
},
async loadEventItems({commit, getters, state}) {
if (!state.user.token) return;
const load = async (slug) => {
try {
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
if (data && success) {
commit('setItems', {slug, items: data});
}
} catch (e) {
console.error("Error loading items");
if (state.fetchedData.items > Date.now() - 1000 * 60 * 60 * 24) return;
try {
const slug = getters.getEventSlug;
const {data, success} = await getters.session.get(`/2/${slug}/items/`);
if (data && success) {
commit('setItems', {slug, items: data});
}
}
const slug = getters.getEventSlug;
if (slug === 'all') {
await Promise.all(state.events.map(e => load(e.slug)));
} else {
await load(slug);
} catch (e) {
console.error("Error loading items");
}
},
async searchEventItems({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const load = async (slug) => {
if (Object.keys(state.loadedItemSearchResults).includes(slug + '/' + encoded_query)) return;
const {
data, success
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
if (data && success) {
commit('setItemSearchResults', {slug, query: encoded_query, items: data});
}
}
const slug = getters.getEventSlug;
if (slug === 'all') {
await Promise.all(state.events.map(e => load(e.slug)));
} else {
await load(slug);
const {
data, success
} = await getters.session.get(`/2/${slug}/items/${encoded_query}/`);
if (data && success) {
commit('setItems', {slug, items: data});
}
},
async loadBoxes({commit, state, getters}) {
@ -474,19 +428,11 @@ const store = createStore({
},
async searchEventTickets({commit, getters, state}, query) {
const encoded_query = base64.encode(utf8.encode(query));
const load = async (slug) => {
if (Object.keys(state.loadedTicketSearchResults).includes(slug + '/' + encoded_query)) return;
const {
data, success
} = await getters.session.get(`/2/${slug}/tickets/${encoded_query}/`);
if (data && success) commit('setTicketSearchResults', {slug, query: encoded_query, items: data});
}
const slug = getters.getEventSlug;
if (slug === 'all') {
await Promise.all(state.events.map(e => load(e.slug)));
} else {
await load(slug);
}
const {
data, success
} = await getters.session.get(`/2/${getters.getEventSlug}/tickets/${encoded_query}/`);
if (data && success) commit('replaceTickets', data);
},
async sendMail({commit, dispatch, state, getters}, {id, message}) {
const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message},
@ -497,8 +443,7 @@ const store = createStore({
}
},
async postManualTicket({commit, dispatch, state, getters}, {sender, message, title,}) {
const slug = getters.getEventSlug;
const {data, success} = await getters.session.post(`/2/${slug !== 'all' ? slug : 'none'}/tickets/manual/`, {
const {data, success} = await getters.session.post(`/2/tickets/manual/`, {
name: title, sender, body: message, recipient: 'mail@c3lf.de'
});
await dispatch('loadTickets');
@ -511,10 +456,7 @@ const store = createStore({
}
},
async postItemComment({commit, dispatch, state, getters}, {id, message}) {
const {
data,
success
} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/${id}/comment/`, {comment: message});
if (data && success) {
state.fetchedData.items = 0;
await dispatch('loadEventItems');
@ -563,29 +505,12 @@ const store = createStore({
state.fetchedData.tickets = 0;
await Promise.all([dispatch('loadTickets'), dispatch('fetchShippingVouchers')]);
}
},
async linkTicketItem({dispatch, state, getters}, {ticket_id, item_id}) {
const {data, success} = await getters.session.post(`/2/matches/`, {issue_thread: ticket_id, item: item_id});
if (data && success) {
state.fetchedData.tickets = 0;
state.fetchedData.items = 0;
await Promise.all([dispatch('loadTickets'), dispatch('loadEventItems')]);
}
}
},
plugins: [persistentStatePlugin({ // TODO change remember to some kind of enable field
prefix: "lf_",
debug: false,
isLoadedKey: "persistent_loaded",
validate: (state) => {
if (state.user && state.user.expiry && state.user.token) {
const as_date = new Date(state.user.expiry);
if (as_date < new Date()) {
state.user.token = null;
state.user.expiry = null;
}
}
},
state: ["remember", "user", "events", "lastUsed",]
}), sharedStatePlugin({
debug: false,

View file

@ -1,5 +1,3 @@
import store from '@/store'
function ticketStateColorLookup(ticket) {
if (ticket.startsWith('closed_')) {
return 'secondary';
@ -38,8 +36,6 @@ const http = {
"Authorization": `Token ${token}`,
},
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -55,8 +51,6 @@ const http = {
},
body: JSON.stringify(data),
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -72,8 +66,6 @@ const http = {
},
body: JSON.stringify(data),
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -89,8 +81,6 @@ const http = {
},
body: JSON.stringify(data),
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 200 || response.status === 201;
return {data: await response.json() || {}, success};
},
@ -105,34 +95,17 @@ const http = {
"Authorization": `Token ${token}`,
},
});
if (response.status === 401)
throw {http_status: response.status};
const success = response.status === 204;
return {data: await response.text() || {}, success};
}
}
const http_session = token => ({
get: async (url) => await http.get(url, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
post: async (url, data) => await http.post(url, data, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
put: async (url, data) => await http.put(url, data, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
patch: async (url, data) => await http.patch(url, data, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
delete: async (url) => await http.delete(url, token).catch((e) => {
if (e.http_status === 401) store.commit('logout');
return {data: {}, success: false};
}),
get: async (url) => await http.get(url, token),
post: async (url, data) => await http.post(url, data, token),
put: async (url, data) => await http.put(url, data, token),
patch: async (url, data) => await http.patch(url, data, token),
delete: async (url) => await http.delete(url, token),
});
export {ticketStateColorLookup, ticketStateIconLookup, http, http_session};

View file

@ -112,12 +112,14 @@ import InputString from "@/components/inputs/InputString.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import InputPhoto from "@/components/inputs/InputPhoto.vue";
import Modal from "@/components/Modal.vue";
import EditItem from "@/components/EditItem.vue";
import AsyncButton from "@/components/inputs/AsyncButton.vue";
export default {
name: 'Item',
components: {
AsyncButton,
EditItem,
Modal, InputPhoto, AuthenticatedImage, InputString, InputCombo, AsyncLoader, ClipboardButton, Timeline
},
data() {

View file

@ -1,114 +0,0 @@
<template>
<AsyncLoader :loaded="isItemsSearchLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'description', 'box']"
:items="getItemsSearchResults"
:keyName="'id'"
@itemActivated="showItemDetail"
>
<template #actions="{ item }">
<div class="btn-group">
<button class="btn btn-success"
@click.stop="confirm('return Item?') && markItemReturned(item)"
title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-secondary" @click.stop="openEditingModalWith(item)" title="edit">
<font-awesome-icon icon="edit"/>
</button>
<button class="btn btn-danger" @click.stop="confirm('delete Item?') && deleteItem(item)"
title="delete">
<font-awesome-icon icon="trash"/>
</button>
</div>
</template>
</Table>
</div>
</div>
<Cards
v-if="layout === 'cards'"
:columns="['id', 'description', 'box']"
:items="getItemsSearchResults"
:keyName="'id'"
v-slot="{ item }"
@itemActivated="item => openLightboxModalWith(item.file)"
>
<AuthenticatedImage v-if="item.file" cached
:src="`/media/2/256/${item.file}/`"
class="card-img-top img-fluid"
/>
<div class="card-body">
<h6 class="card-title">{{ item.description }}</h6>
<h6 class="card-subtitle text-secondary">id: {{ item.id }} box: {{ item.box }}</h6>
<div class="row mx-auto mt-2">
<div class="btn-group">
<button class="btn btn-outline-success"
@click.stop="confirm('return Item?') && markItemReturned(item)" title="returned">
<font-awesome-icon icon="check"/>
</button>
<button class="btn btn-outline-secondary" @click.stop="showItemDetail(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>
</div>
</div>
</Cards>
</div>
</AsyncLoader>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table';
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'Items',
data: () => ({
lightboxHash: null,
editingItem: null,
}),
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal},
computed: {
...mapGetters(['getItemsSearchResults', 'isItemsSearchLoaded', 'layout', 'getEventSlug', 'searchQuery']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'searchEventItems', 'updateItem', 'scheduleAfterInit']),
...mapMutations(['openLightboxModalWith']),
showItemDetail(item) {
router.push({name: 'item', params: {id: item.id}});
},
confirm(message) {
return window.confirm(message);
}
},
watch: {
searchQuery() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
},
getEventSlug() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.searchEventItems(this.searchQuery)]);
}
};
</script>
<style scoped>
</style>

View file

@ -67,10 +67,11 @@
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from 'vuex';
import Table from '@/components/Table';
import Cards from '@/components/Cards';
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
import {mapActions, mapGetters, mapMutations} from 'vuex';
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
@ -81,9 +82,9 @@ export default {
lightboxHash: null,
editingItem: null,
}),
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal},
components: {AsyncLoader, AuthenticatedImage, Table, Cards, Modal, EditItem},
computed: {
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout', 'getEventSlug']),
...mapGetters(['getEventItems', 'isItemsLoaded', 'layout']),
},
methods: {
...mapActions(['deleteItem', 'markItemReturned', 'loadEventItems', 'updateItem', 'scheduleAfterInit']),
@ -95,11 +96,6 @@ export default {
return window.confirm(message);
}
},
watch: {
getEventSlug() {
this.scheduleAfterInit(() => [this.loadEventItems()]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.loadEventItems()]);
}

View file

@ -72,7 +72,7 @@ export default {
name: 'Login',
data() {
return {
msg: 'Lost&Found Team Login',
msg: 'Welcome to ' + window.location.hostname,
username: '',
password: '',
remember: false

View file

@ -87,7 +87,7 @@ export default {
name: 'Register',
data() {
return {
msg: 'Register as team member',
msg: 'Register',
password2: '',
form: {
username: '',

View file

@ -17,7 +17,7 @@
<textarea placeholder="add comment..." v-model="newComment"
class="form-control">
</textarea>
<AsyncButton class="btn btn-secondary float-right" :task="addCommentAndClear" :disabled="!newComment">
<AsyncButton class="btn btn-primary float-right" :task="addCommentAndClear">
<font-awesome-icon icon="comment"/>
Save Comment
</AsyncButton>
@ -25,7 +25,7 @@
</div>
</template>
<template v-slot:timeline_action2>
<span class="timeline-item-icon | filled-icon">
<span class="timeline-item-icon | faded-icon">
<font-awesome-icon icon="envelope"/>
</span>
<div class="new-mail card bg-dark">
@ -35,7 +35,7 @@
<div>
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
</textarea>
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear" :disabled="!newMail">
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
<font-awesome-icon icon="envelope"/>
Send Mail
</AsyncButton>
@ -81,13 +81,6 @@
<font-awesome-icon icon="clipboard"/>
Copy&nbsp;DHL&nbsp;contact&nbsp;to&nbsp;clipboard
</ClipboardButton>
<div class="btn-group">
<input type="text" class="form-control" v-model="item_id">
<button class="form-control btn btn-success" :disabled="!item_id"
@click="linkTicketItem({ticket_id: ticket.id, item_id: parseInt(item_id)}).then(()=>item_id='')">
Link&nbsp;Item
</button>
</div>
<div class="btn-group">
<select class="form-control" v-model="shipping_voucher_type">
<option v-for="type in availableShippingVoucherTypes.filter(t=>t.count>0)"
@ -148,7 +141,6 @@ export default {
selected_state: null,
selected_assignee: null,
shipping_voucher_type: null,
item_id: "",
newMail: "",
newComment: ""
}
@ -174,7 +166,6 @@ export default {
...mapActions(['deleteItem', 'markItemReturned', 'sendMail', 'updateTicketPartial', 'postComment']),
...mapActions(['loadTickets', 'fetchTicketStates', 'loadUsers', 'scheduleAfterInit']),
...mapActions(['claimShippingVoucher', 'fetchShippingVouchers']),
...mapActions(['linkTicketItem']),
...mapMutations(['openLightboxModalWith']),
changeTicketStatus() {
this.ticket.state = this.selected_state;
@ -207,10 +198,10 @@ export default {
},
mounted() {
this.scheduleAfterInit(() => [Promise.all([this.fetchTicketStates(), this.loadTickets(), this.loadUsers(), this.fetchShippingVouchers()]).then(() => {
//if (this.ticket.state === "pending_new") {
// this.selected_state = "pending_open";
// this.changeTicketStatus()
//}
if (this.ticket.state === "pending_new") {
this.selected_state = "pending_open";
this.changeTicketStatus()
}
this.selected_state = this.ticket.state;
this.selected_assignee = this.ticket.assigned_to
})]);

View file

@ -1,102 +0,0 @@
<template>
<AsyncLoader :loaded="isTicketsSearchLoaded">
<div class="container-fluid px-xl-5 mt-3">
<div class="row" v-if="layout === 'table'">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['id', 'name', 'state', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:items="getTicketsSearchResults.map(formatTicket)"
:keyName="'id'"
>
<template v-slot:actions="{item}">
<div class="btn-group">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</router-link>
</div>
</template>
</Table>
</div>
</div>
<CollapsableCards v-if="layout === 'tasks'" :items="getTicketsSearchResults"
:columns="['id', 'name', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
'pending_physical_confirmation','pending_return','pending_postponed','pending_suspected_spam'].map(stateInfo)">
<template #section_header="{index, section, count}">
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
</template>
<template #section_body="{item}">
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.last_activity }}</td>
<td>{{ item.assigned_to }}</td>
<td v-if="getEventSlug==='all'">{{ item.event }}</td>
<td>
<div class="btn-group">
<router-link :to="{name: 'ticket', params: {id: item.id}}" class="btn btn-primary"
title="view">
<font-awesome-icon icon="eye"/>
View
</router-link>
</div>
</td>
</tr>
</template>
</CollapsableCards>
</div>
</AsyncLoader>
</template>
<script>
import Cards from '@/components/Cards';
import {mapActions, mapGetters, mapState} from 'vuex';
import Table from '@/components/Table';
import CollapsableCards from "@/components/CollapsableCards.vue";
import AsyncLoader from "@/components/AsyncLoader.vue";
import router from "@/router";
export default {
name: 'TicketSearch',
components: {AsyncLoader, Table, Cards, CollapsableCards},
computed: {
...mapGetters(['getTicketsSearchResults', 'isTicketsSearchLoaded', 'stateInfo', 'getEventSlug', 'layout', 'searchQuery']),
},
methods: {
...mapActions(['searchEventTickets', 'fetchTicketStates', 'scheduleAfterInit']),
gotoDetail(ticket) {
router.push({name: 'ticket', params: {id: ticket.id}});
},
formatTicket(ticket) {
return {
id: ticket.id,
name: ticket.name,
state: this.stateInfo(ticket.state).text,
stateColor: this.stateInfo(ticket.state).color,
last_activity: ticket.last_activity,
assigned_to: ticket.assigned_to,
event: ticket.event
};
}
},
watch: {
searchQuery() {
this.scheduleAfterInit(() => [this.searchEventTickets(this.searchQuery)]);
},
getEventSlug() {
this.scheduleAfterInit(() => [this.searchEventTickets(this.searchQuery)]);
}
},
mounted() {
this.scheduleAfterInit(() => [this.fetchTicketStates(), this.searchEventTickets(this.searchQuery)]);
}
};
</script>
<style scoped>
</style>

View file

@ -26,7 +26,7 @@
:columns="['id', 'name', 'last_activity', 'assigned_to',
...(getEventSlug==='all'?['event']:[])]"
:keyName="'state'" :sections="['pending_new', 'pending_open','pending_shipping',
'pending_physical_confirmation','pending_return','pending_postponed','pending_suspected_spam'].map(stateInfo)">
'pending_physical_confirmation','pending_return','pending_postponed'].map(stateInfo)">
<template #section_header="{index, section, count}">
{{ section.text }} <span class="badge badge-light ml-1">{{ count }}</span>
</template>

View file

@ -1,7 +1,6 @@
<template>
<AsyncLoader :loaded="events.length > 0">
<ExpandableTable v-if="!!events" :columns="['slug', 'name']" :items="events.map((e,i)=>({idx: i, ...e}))"
:keyName="'slug'">
<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"/>
@ -44,7 +43,7 @@
<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(item.idx, a_id)">
@click.stop="deleteAddress(id, a_id)">
<button class="btn btn-secondary" disabled style="opacity: 1">
{{ address }}
</button>
@ -53,9 +52,8 @@
</button>
</div>
<div class="btn-group btn-group-sm">
<input type="text" v-model="new_address[item.idx]">
<button class="btn btn-secondary" @click.stop="addAddress(item.idx)"
style="white-space: nowrap;">
<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"/>&nbsp;add
</button>
</div>
@ -90,11 +88,11 @@ export default {
if (!this.events[id].addresses.includes(a))
this.events[id].addresses.push(a)
this.new_address[id] = ""
this.updateEvent({id: this.events[id].id, partial_event: {addresses: this.events[id].addresses}});
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].id, partial_event: {addresses: this.events[id].addresses}});
this.updateEvent({id: this.events[id].eid, partial_event: {addresses: this.events[id].addresses}});
}
},
};