Compare commits
11 commits
Author | SHA1 | Date | |
---|---|---|---|
9def22a836 | |||
8d45fef627 | |||
756fe4aaad | |||
51ddc8edc3 | |||
e8a92b26fa | |||
d80fb60afd | |||
6b0def543c | |||
1568252112 | |||
9e0540d133 | |||
c2bcd53749 | |||
86b4220eaa |
25 changed files with 10033 additions and 114 deletions
158
README.md
Normal file
158
README.md
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
# 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`**
|
0
core/.local/.forgit_fordocker
Normal file
0
core/.local/.forgit_fordocker
Normal file
68
core/README.md
Normal file
68
core/README.md
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
# 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`.
|
|
@ -3,18 +3,20 @@ from prometheus_client.core import CounterMetricFamily, REGISTRY
|
||||||
from django.db.models import Case, Value, When, BooleanField, Count
|
from django.db.models import Case, Value, When, BooleanField, Count
|
||||||
from inventory.models import Item
|
from inventory.models import Item
|
||||||
|
|
||||||
|
|
||||||
class ItemCountCollector(object):
|
class ItemCountCollector(object):
|
||||||
|
|
||||||
def collect(self):
|
def collect(self):
|
||||||
counter = CounterMetricFamily("item_count", "Current number of items", labels=['event', 'returned_state'])
|
try:
|
||||||
|
counter = CounterMetricFamily("item_count", "Current number of items", labels=['event', 'returned_state'])
|
||||||
|
|
||||||
yield counter
|
yield counter
|
||||||
|
|
||||||
if not apps.models_ready or not apps.apps_ready:
|
if not apps.models_ready or not apps.apps_ready:
|
||||||
return
|
return
|
||||||
|
|
||||||
queryset = (
|
queryset = (
|
||||||
Item.all_objects
|
Item.all_objects
|
||||||
.annotate(
|
.annotate(
|
||||||
returned=Case(
|
returned=Case(
|
||||||
When(returned_at__isnull=True, then=Value(False)),
|
When(returned_at__isnull=True, then=Value(False)),
|
||||||
|
@ -25,11 +27,14 @@ class ItemCountCollector(object):
|
||||||
.values('event__slug', 'returned', 'event_id')
|
.values('event__slug', 'returned', 'event_id')
|
||||||
.annotate(amount=Count('id'))
|
.annotate(amount=Count('id'))
|
||||||
.order_by('event__slug', 'returned') # Optional: order by slug and returned
|
.order_by('event__slug', 'returned') # Optional: order by slug and returned
|
||||||
)
|
)
|
||||||
|
|
||||||
for e in queryset:
|
for e in queryset:
|
||||||
counter.add_metric([e["event__slug"].lower(), str(e["returned"])], e["amount"])
|
counter.add_metric([e["event__slug"].lower(), str(e["returned"])], e["amount"])
|
||||||
|
|
||||||
yield counter
|
yield counter
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
REGISTRY.register(ItemCountCollector())
|
|
||||||
|
REGISTRY.register(ItemCountCollector())
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django_softdelete.models import SoftDeleteModel
|
|
||||||
|
|
||||||
from inventory.models import Item
|
from inventory.models import Item
|
||||||
|
|
||||||
|
@ -10,7 +9,8 @@ def hash_upload(instance, filename):
|
||||||
|
|
||||||
|
|
||||||
class FileManager(models.Manager):
|
class FileManager(models.Manager):
|
||||||
def get_or_create(self, **kwargs):
|
|
||||||
|
def __file_data_helper(self, **kwargs):
|
||||||
if 'data' in kwargs and type(kwargs['data']) == str:
|
if 'data' in kwargs and type(kwargs['data']) == str:
|
||||||
import base64
|
import base64
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
@ -31,6 +31,10 @@ class FileManager(models.Manager):
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise ValueError('data must be a base64 encoded string or file and hash must be provided')
|
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:
|
try:
|
||||||
return self.get(hash=kwargs['hash']), False
|
return self.get(hash=kwargs['hash']), False
|
||||||
except self.model.DoesNotExist:
|
except self.model.DoesNotExist:
|
||||||
|
@ -39,26 +43,7 @@ class FileManager(models.Manager):
|
||||||
return obj, True
|
return obj, True
|
||||||
|
|
||||||
def create(self, **kwargs):
|
def create(self, **kwargs):
|
||||||
if 'data' in kwargs and type(kwargs['data']) == str:
|
kwargs = self.__file_data_helper(**kwargs)
|
||||||
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():
|
if not self.filter(hash=kwargs['hash']).exists():
|
||||||
obj = super().create(**kwargs)
|
obj = super().create(**kwargs)
|
||||||
obj.file.save(content=kwargs['file'], name=kwargs['hash'])
|
obj.file.save(content=kwargs['file'], name=kwargs['hash'])
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
|
|
||||||
from django.db import models
|
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
|
from django_softdelete.models import SoftDeleteModel, SoftDeleteManager
|
||||||
|
|
||||||
|
|
||||||
|
@ -64,6 +67,11 @@ class Item(SoftDeleteModel):
|
||||||
return '[' + str(self.id) + ']' + self.description
|
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):
|
class Container(SoftDeleteModel):
|
||||||
id = models.AutoField(primary_key=True)
|
id = models.AutoField(primary_key=True)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
|
|
@ -132,6 +132,22 @@ class ItemSerializer(BasicItemSerializer):
|
||||||
'cid': placement.container.id,
|
'cid': placement.container.id,
|
||||||
'box': placement.container.name
|
'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'])
|
return sorted(timeline, key=lambda x: x['timestamp'])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -63,28 +63,28 @@ class ItemTestCase(TestCase):
|
||||||
self.assertEqual(response.json()[0]['file'], None)
|
self.assertEqual(response.json()[0]['file'], None)
|
||||||
self.assertEqual(response.json()[0]['returned'], False)
|
self.assertEqual(response.json()[0]['returned'], False)
|
||||||
self.assertEqual(response.json()[0]['event'], self.event.slug)
|
self.assertEqual(response.json()[0]['event'], self.event.slug)
|
||||||
self.assertEqual(len(response.json()[0]['timeline']), 4)
|
self.assertEqual(len(response.json()[0]['timeline']), 5)
|
||||||
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'placement')
|
self.assertEqual(response.json()[0]['timeline'][0]['type'], 'created')
|
||||||
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'comment')
|
self.assertEqual(response.json()[0]['timeline'][1]['type'], 'placement')
|
||||||
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'issue_relation')
|
self.assertEqual(response.json()[0]['timeline'][2]['type'], 'comment')
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'placement')
|
self.assertEqual(response.json()[0]['timeline'][3]['type'], 'issue_relation')
|
||||||
self.assertEqual(response.json()[0]['timeline'][1]['id'], comment.id)
|
self.assertEqual(response.json()[0]['timeline'][4]['type'], 'placement')
|
||||||
self.assertEqual(response.json()[0]['timeline'][2]['id'], match.id)
|
self.assertEqual(response.json()[0]['timeline'][2]['id'], comment.id)
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['id'], placement.id)
|
self.assertEqual(response.json()[0]['timeline'][3]['id'], match.id)
|
||||||
self.assertEqual(response.json()[0]['timeline'][0]['box'], 'BOX1')
|
self.assertEqual(response.json()[0]['timeline'][4]['id'], placement.id)
|
||||||
self.assertEqual(response.json()[0]['timeline'][0]['cid'], self.box1.id)
|
self.assertEqual(response.json()[0]['timeline'][1]['box'], 'BOX1')
|
||||||
self.assertEqual(response.json()[0]['timeline'][1]['comment'], 'test')
|
self.assertEqual(response.json()[0]['timeline'][1]['cid'], self.box1.id)
|
||||||
self.assertEqual(response.json()[0]['timeline'][1]['timestamp'],
|
self.assertEqual(response.json()[0]['timeline'][0]['timestamp'], item.created_at.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||||
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
self.assertEqual(response.json()[0]['timeline'][2]['comment'], 'test')
|
||||||
self.assertEqual(response.json()[0]['timeline'][2]['status'], 'possible')
|
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'], comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||||
self.assertEqual(response.json()[0]['timeline'][2]['timestamp'],
|
self.assertEqual(response.json()[0]['timeline'][3]['status'], 'possible')
|
||||||
match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
self.assertEqual(response.json()[0]['timeline'][3]['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'][3]['issue_thread']['name'], "test issue")
|
||||||
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['event'], "EVENT")
|
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['event'], "EVENT")
|
||||||
self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['state'], "pending_new")
|
self.assertEqual(response.json()[0]['timeline'][3]['issue_thread']['state'], "pending_new")
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['box'], 'BOX2')
|
self.assertEqual(response.json()[0]['timeline'][4]['box'], 'BOX2')
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['cid'], self.box2.id)
|
self.assertEqual(response.json()[0]['timeline'][4]['cid'], self.box2.id)
|
||||||
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
|
self.assertEqual(response.json()[0]['timeline'][4]['timestamp'],
|
||||||
placement.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
placement.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
|
||||||
self.assertEqual(len(response.json()[0]['related_issues']), 1)
|
self.assertEqual(len(response.json()[0]['related_issues']), 1)
|
||||||
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
|
self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue")
|
||||||
|
|
|
@ -93,11 +93,11 @@ def make_reply(reply_email, references=None, event=None):
|
||||||
reply_email.save()
|
reply_email.save()
|
||||||
if references:
|
if references:
|
||||||
reply["References"] = " ".join(references)
|
reply["References"] = " ".join(references)
|
||||||
|
if reply_email.body != "":
|
||||||
reply.set_content(reply_email.body)
|
reply.set_content(reply_email.body)
|
||||||
|
return reply
|
||||||
return reply
|
else:
|
||||||
|
raise SpecialMailException("mail content emty")
|
||||||
|
|
||||||
async def send_smtp(message):
|
async def send_smtp(message):
|
||||||
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
|
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
|
||||||
|
@ -303,10 +303,10 @@ class LMTPHandler:
|
||||||
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received',
|
||||||
reference=email.id)
|
reference=email.id)
|
||||||
log.info(f"Created system event {systemevent.id}")
|
log.info(f"Created system event {systemevent.id}")
|
||||||
channel_layer = get_channel_layer()
|
#channel_layer = get_channel_layer()
|
||||||
await channel_layer.group_send(
|
#await channel_layer.group_send(
|
||||||
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
# 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
|
||||||
"message": "email received"})
|
# "message": "email received"})
|
||||||
log.info(f"Sent message to frontend")
|
log.info(f"Sent message to frontend")
|
||||||
if new and reply:
|
if new and reply:
|
||||||
log.info('Sending message to %s' % reply['To'])
|
log.info('Sending message to %s' % reply['To'])
|
||||||
|
|
|
@ -13,7 +13,7 @@ Automat==22.10.0
|
||||||
beautifulsoup4==4.12.2
|
beautifulsoup4==4.12.2
|
||||||
bs4==0.0.1
|
bs4==0.0.1
|
||||||
certifi==2023.11.17
|
certifi==2023.11.17
|
||||||
cffi==1.16.0
|
#cffi==1.16.0
|
||||||
channels==4.0.0
|
channels==4.0.0
|
||||||
channels-redis==4.1.0
|
channels-redis==4.1.0
|
||||||
charset-normalizer==3.3.2
|
charset-normalizer==3.3.2
|
||||||
|
@ -40,12 +40,12 @@ inflection==0.5.1
|
||||||
itypes==1.2.0
|
itypes==1.2.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
MarkupSafe==2.1.3
|
MarkupSafe==2.1.3
|
||||||
msgpack==1.0.7
|
#msgpack==1.0.7
|
||||||
msgpack-python==0.5.6
|
#msgpack-python==0.5.6
|
||||||
multidict==6.0.5
|
multidict==6.0.5
|
||||||
openapi-codec==1.3.2
|
openapi-codec==1.3.2
|
||||||
packaging==23.2
|
packaging==23.2
|
||||||
Pillow==10.1.0
|
Pillow==11.1.0
|
||||||
pyasn1==0.5.1
|
pyasn1==0.5.1
|
||||||
pyasn1-modules==0.3.0
|
pyasn1-modules==0.3.0
|
||||||
pycares==4.4.0
|
pycares==4.4.0
|
||||||
|
@ -69,7 +69,6 @@ typing_extensions==4.8.0
|
||||||
uritemplate==4.1.1
|
uritemplate==4.1.1
|
||||||
urllib3==2.1.0
|
urllib3==2.1.0
|
||||||
uvicorn==0.24.0.post1
|
uvicorn==0.24.0.post1
|
||||||
watchfiles==0.21.0
|
|
||||||
websockets==12.0
|
websockets==12.0
|
||||||
yarl==1.9.4
|
yarl==1.9.4
|
||||||
zope.interface==6.1
|
zope.interface==6.1
|
||||||
|
|
|
@ -102,12 +102,6 @@ def manual_ticket(request, event_slug):
|
||||||
subject=request.data['name'],
|
subject=request.data['name'],
|
||||||
body=request.data['body'],
|
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)
|
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
@ -133,12 +127,6 @@ def add_comment(request, pk):
|
||||||
issue_thread=issue,
|
issue_thread=issue,
|
||||||
comment=request.data['comment'],
|
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)
|
return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
|
18
core/tickets/migrations/0013_alter_statechange_state.py
Normal file
18
core/tickets/migrations/0013_alter_statechange_state.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# 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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,13 +1,8 @@
|
||||||
FROM python:3.11-bookworm
|
FROM python:3.11-slim-bookworm
|
||||||
LABEL authors="lagertonne"
|
LABEL authors="lagertonne"
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
RUN mkdir /code
|
RUN mkdir /code
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
COPY requirements.dev.txt /code/
|
COPY requirements.dev.txt /code/
|
||||||
COPY requirements.prod.txt /code/
|
RUN pip install -r requirements.dev.txt
|
||||||
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/
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM docker.io/node:22
|
FROM node:22-alpine
|
||||||
|
|
||||||
RUN mkdir /web
|
RUN mkdir /web
|
||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
name: c3lf-sys3-dev
|
||||||
services:
|
services:
|
||||||
core:
|
core:
|
||||||
build:
|
build:
|
||||||
|
@ -6,10 +7,12 @@ services:
|
||||||
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 testdata.py && python manage.py runserver 0.0.0.0:8000'
|
||||||
environment:
|
environment:
|
||||||
- HTTP_HOST=core
|
- HTTP_HOST=core
|
||||||
- DB_FILE=dev.db
|
- DB_FILE=.local/dev.db
|
||||||
|
- DEBUG_MODE_ACTIVE=true
|
||||||
volumes:
|
volumes:
|
||||||
- ../../core:/code
|
- ../../core:/code:ro
|
||||||
- ../testdata.py:/code/testdata.py
|
- ../testdata.py:/code/testdata.py:ro
|
||||||
|
- backend_context:/code/.local
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
|
@ -19,10 +22,12 @@ services:
|
||||||
dockerfile: ../deploy/dev/Dockerfile.frontend
|
dockerfile: ../deploy/dev/Dockerfile.frontend
|
||||||
command: npm run serve
|
command: npm run serve
|
||||||
volumes:
|
volumes:
|
||||||
- ../../web:/web:ro
|
- ../../web/src:/web/src
|
||||||
- /web/node_modules
|
|
||||||
- ./vue.config.js:/web/vue.config.js
|
- ./vue.config.js:/web/vue.config.js
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- core
|
- core
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backend_context:
|
|
@ -1,11 +1,11 @@
|
||||||
FROM python:3.11-bookworm
|
FROM python:3.11-slim-bookworm
|
||||||
LABEL authors="lagertonne"
|
LABEL authors="lagertonne"
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
RUN mkdir /code
|
RUN mkdir /code
|
||||||
WORKDIR /code
|
WORKDIR /code
|
||||||
COPY requirements.prod.txt /code/
|
RUN apt update && apt install -y pkg-config mariadb-client default-libmysqlclient-dev build-essential
|
||||||
RUN apt update && apt install -y mariadb-client
|
|
||||||
RUN pip install -r requirements.prod.txt
|
|
||||||
RUN pip install mysqlclient
|
RUN pip install mysqlclient
|
||||||
|
COPY requirements.prod.txt /code/
|
||||||
|
RUN pip install -r requirements.prod.txt
|
||||||
COPY .. /code/
|
COPY .. /code/
|
|
@ -1,4 +1,4 @@
|
||||||
FROM docker.io/node:22
|
FROM node:22-alpine
|
||||||
|
|
||||||
RUN mkdir /web
|
RUN mkdir /web
|
||||||
WORKDIR /web
|
WORKDIR /web
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
name: c3lf-sys3-testing
|
||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis
|
image: redis
|
||||||
|
@ -31,8 +32,9 @@ services:
|
||||||
- DB_PASSWORD=system3
|
- DB_PASSWORD=system3
|
||||||
- MAIL_DOMAIN=mail:1025
|
- MAIL_DOMAIN=mail:1025
|
||||||
volumes:
|
volumes:
|
||||||
- ../../core:/code
|
- ../../core:/code:ro
|
||||||
- ../testdata.py:/code/testdata.py
|
- ../testdata.py:/code/testdata.py:ro
|
||||||
|
- backend_context:/code
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -47,8 +49,8 @@ services:
|
||||||
command: npm run serve
|
command: npm run serve
|
||||||
volumes:
|
volumes:
|
||||||
- ../../web:/web:ro
|
- ../../web:/web:ro
|
||||||
- /web/node_modules
|
- ./vue.config.js:/web/vue.config.js:ro
|
||||||
- ./vue.config.js:/web/vue.config.js
|
- frontend_context:/web
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
@ -70,3 +72,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
mariadb_data:
|
mariadb_data:
|
||||||
mailpit_data:
|
mailpit_data:
|
||||||
|
frontend_context:
|
||||||
|
backend_context:
|
||||||
|
|
9399
web/package-lock.json
generated
Normal file
9399
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -24,6 +24,15 @@
|
||||||
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'placement'">
|
<span class="timeline-item-icon faded-icon" v-else-if="item.type === 'placement'">
|
||||||
<font-awesome-icon icon="archive"/>
|
<font-awesome-icon icon="archive"/>
|
||||||
</span>
|
</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>
|
<span class="timeline-item-icon faded-icon" v-else>
|
||||||
<font-awesome-icon icon="pen"/>
|
<font-awesome-icon icon="pen"/>
|
||||||
</span>
|
</span>
|
||||||
|
@ -35,6 +44,9 @@
|
||||||
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
|
<TimelineShippingVoucher v-else-if="item.type === 'shipping_voucher'" :item="item"/>
|
||||||
<TimelinePlacement v-else-if="item.type === 'placement'" :item="item"/>
|
<TimelinePlacement v-else-if="item.type === 'placement'" :item="item"/>
|
||||||
<TimelineRelatedTicket v-else-if="item.type === 'issue_relation'" :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>
|
<p v-else>{{ item }}</p>
|
||||||
</li>
|
</li>
|
||||||
<li class="timeline-item">
|
<li class="timeline-item">
|
||||||
|
@ -58,10 +70,16 @@ import TimelineShippingVoucher from "@/components/TimelineShippingVoucher.vue";
|
||||||
import AsyncButton from "@/components/inputs/AsyncButton.vue";
|
import AsyncButton from "@/components/inputs/AsyncButton.vue";
|
||||||
import TimelinePlacement from "@/components/TimelinePlacement.vue";
|
import TimelinePlacement from "@/components/TimelinePlacement.vue";
|
||||||
import TimelineRelatedTicket from "@/components/TimelineRelatedTicket.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 {
|
export default {
|
||||||
name: 'Timeline',
|
name: 'Timeline',
|
||||||
components: {
|
components: {
|
||||||
|
TimelineDeleted,
|
||||||
|
TimelineReturned,
|
||||||
|
TimelineCreated,
|
||||||
TimelineRelatedTicket,
|
TimelineRelatedTicket,
|
||||||
TimelinePlacement,
|
TimelinePlacement,
|
||||||
TimelineShippingVoucher,
|
TimelineShippingVoucher,
|
||||||
|
@ -203,4 +221,4 @@ a {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
83
web/src/components/TimelineCreated.vue
Normal file
83
web/src/components/TimelineCreated.vue
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<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>
|
83
web/src/components/TimelineDeleted.vue
Normal file
83
web/src/components/TimelineDeleted.vue
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<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>
|
83
web/src/components/TimelineReturned.vue
Normal file
83
web/src/components/TimelineReturned.vue
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<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>
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<button @click.stop="handleClick" :disabled="disabled">
|
<button @click.stop="handleClick" :disabled="disabled || inProgress">
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"
|
||||||
:class="{'d-none': !disabled}"></span>
|
:class="{'d-none': !inProgress}"></span>
|
||||||
<span class="ml-2" :class="{'d-none': !disabled}">In Progress...</span>
|
<span class="ml-2" :class="{'d-none': !inProgress}">In Progress...</span>
|
||||||
<span :class="{'d-none': disabled}"><slot></slot></span>
|
<span :class="{'d-none': inProgress}"><slot></slot></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ export default {
|
||||||
name: 'AsyncButton',
|
name: 'AsyncButton',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
disabled: false,
|
inProgress: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
@ -21,17 +21,21 @@ export default {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async handleClick() {
|
async handleClick() {
|
||||||
if (this.task && typeof this.task === 'function') {
|
if (this.task && typeof this.task === 'function') {
|
||||||
this.disabled = true;
|
this.inProgress = true;
|
||||||
try {
|
try {
|
||||||
await this.task();
|
await this.task();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
this.disabled = false;
|
this.inProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -43,4 +47,4 @@ export default {
|
||||||
.spinner-border {
|
.spinner-border {
|
||||||
vertical-align: -0.125em;
|
vertical-align: -0.125em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
<textarea placeholder="add comment..." v-model="newComment"
|
<textarea placeholder="add comment..." v-model="newComment"
|
||||||
class="form-control">
|
class="form-control">
|
||||||
</textarea>
|
</textarea>
|
||||||
<AsyncButton class="btn btn-secondary float-right" :task="addCommentAndClear">
|
<AsyncButton class="btn btn-secondary float-right" :task="addCommentAndClear" :disabled="!newComment">
|
||||||
<font-awesome-icon icon="comment"/>
|
<font-awesome-icon icon="comment"/>
|
||||||
Save Comment
|
Save Comment
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
|
@ -35,7 +35,7 @@
|
||||||
<div>
|
<div>
|
||||||
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
<textarea placeholder="reply mail..." v-model="newMail" class="form-control">
|
||||||
</textarea>
|
</textarea>
|
||||||
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear">
|
<AsyncButton class="btn btn-primary float-right" :task="sendMailAndClear" :disabled="!newMail">
|
||||||
<font-awesome-icon icon="envelope"/>
|
<font-awesome-icon icon="envelope"/>
|
||||||
Send Mail
|
Send Mail
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue