Compare commits
No commits in common. "testing" and "live" have entirely different histories.
25 changed files with 113 additions and 10032 deletions
158
README.md
158
README.md
|
@ -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`**
|
|
@ -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`.
|
|
@ -3,11 +3,9 @@ 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
|
||||
|
@ -33,8 +31,5 @@ class ItemCountCollector(object):
|
|||
counter.add_metric([e["event__slug"].lower(), str(e["returned"])], e["amount"])
|
||||
|
||||
yield counter
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
REGISTRY.register(ItemCountCollector())
|
|
@ -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'])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -132,22 +132,6 @@ 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'])
|
||||
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -93,11 +93,11 @@ 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")
|
||||
|
||||
|
||||
async def send_smtp(message):
|
||||
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',
|
||||
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'])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,6 +133,12 @@ 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)
|
||||
|
||||
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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/
|
||||
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/
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:22-alpine
|
||||
FROM docker.io/node:22
|
||||
|
||||
RUN mkdir /web
|
||||
WORKDIR /web
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
name: c3lf-sys3-dev
|
||||
services:
|
||||
core:
|
||||
build:
|
||||
|
@ -7,12 +6,10 @@ services:
|
|||
command: bash -c 'python manage.py migrate && python testdata.py && python manage.py runserver 0.0.0.0:8000'
|
||||
environment:
|
||||
- HTTP_HOST=core
|
||||
- DB_FILE=.local/dev.db
|
||||
- DEBUG_MODE_ACTIVE=true
|
||||
- DB_FILE=dev.db
|
||||
volumes:
|
||||
- ../../core:/code:ro
|
||||
- ../testdata.py:/code/testdata.py:ro
|
||||
- backend_context:/code/.local
|
||||
- ../../core:/code
|
||||
- ../testdata.py:/code/testdata.py
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
|
@ -22,12 +19,10 @@ 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
|
||||
|
||||
volumes:
|
||||
backend_context:
|
|
@ -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/
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:22-alpine
|
||||
FROM docker.io/node:22
|
||||
|
||||
RUN mkdir /web
|
||||
WORKDIR /web
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
name: c3lf-sys3-testing
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
|
@ -32,9 +31,8 @@ services:
|
|||
- DB_PASSWORD=system3
|
||||
- MAIL_DOMAIN=mail:1025
|
||||
volumes:
|
||||
- ../../core:/code:ro
|
||||
- ../testdata.py:/code/testdata.py:ro
|
||||
- backend_context:/code
|
||||
- ../../core:/code
|
||||
- ../testdata.py:/code/testdata.py
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
|
@ -49,8 +47,8 @@ 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:
|
||||
|
@ -72,5 +70,3 @@ services:
|
|||
volumes:
|
||||
mariadb_data:
|
||||
mailpit_data:
|
||||
frontend_context:
|
||||
backend_context:
|
||||
|
|
9399
web/package-lock.json
generated
9399
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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-secondary float-right" :task="addCommentAndClear">
|
||||
<font-awesome-icon icon="comment"/>
|
||||
Save Comment
|
||||
</AsyncButton>
|
||||
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue