diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 6f313c6..0000000 --- a/.editorconfig +++ /dev/null @@ -1,15 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -indent_style = space -indent_size = 4 -trim_trailing_whitespace = true - -[*.md] -trim_trailing_whitespace = false - -[*.yml] -indent_size = 2 diff --git a/.env.example b/.env.example deleted file mode 100644 index ac941f3..0000000 --- a/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -APP_NAME="Lost & Found Backend" -APP_ENV=local -APP_KEY= -APP_DEBUG=false -APP_URL=http://localhost -APP_TIMEZONE=Europe/Berlin - -LOG_CHANNEL=stack -LOG_SLACK_WEBHOOK_URL= - -DB_CONNECTION=mysql -DB_HOST=dbserver -DB_PORT=3306 -DB_DATABASE=lostfound -DB_USERNAME=lostfound -DB_PASSWORD=lostfound - -CACHE_DRIVER=file -QUEUE_CONNECTION=sync diff --git a/.forgejo/issue_template/bug.yml b/.forgejo/issue_template/bug.yml new file mode 100644 index 0000000..8b227a0 --- /dev/null +++ b/.forgejo/issue_template/bug.yml @@ -0,0 +1,35 @@ +name: Bug Report +about: File a bug report +labels: + - Kind/Bug +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: Tell us what you see! + validations: + required: true + - type: dropdown + id: browsers + attributes: + label: What browsers are you seeing the problem on? + multiple: true + options: + - Firefox (Windows) + - Firefox (MacOS) + - Firefox (Linux) + - Firefox (Android) + - Firefox (iOS) + - Chrome (Windows) + - Chrome (MacOS) + - Chrome (Linux) + - Chrome (Android) + - Chrome (iOS) + - Safari + - Microsoft Edge \ No newline at end of file diff --git a/.forgejo/issue_template/feature.yml b/.forgejo/issue_template/feature.yml new file mode 100644 index 0000000..c8cf794 --- /dev/null +++ b/.forgejo/issue_template/feature.yml @@ -0,0 +1,27 @@ +name: 'New Feature' +about: 'This template is for new features' +labels: + - Kind/Feature +body: + - type: markdown + attributes: + value: | + Before creating a Feature Ticket, please check for duplicates. + - type: markdown + attributes: + value: | + ### Implementation Checklist + - [ ] concept + - [ ] frontend + - [ ] backend + - [ ] unittests + - [ ] tested on staging + visible: [ content ] + - type: textarea + id: description + attributes: + label: 'Feature Description' + description: 'Explain the the feature.' + placeholder: Description + validations: + required: true \ No newline at end of file diff --git a/.forgejo/workflows/deploy_staging.yml b/.forgejo/workflows/deploy_staging.yml new file mode 100644 index 0000000..4c5fcd0 --- /dev/null +++ b/.forgejo/workflows/deploy_staging.yml @@ -0,0 +1,60 @@ +on: + push: + branches: + - testing + +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 -p ~/.ssh + echo "${{ secrets.C3LF_SSH_TESTING }}" > ~/.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 "andromeda.lab.or.it ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDXPoO0PE+B9PYwbGaLo98zhbmjAkp6eBtVeZe43v/+T" >> ~/.ssh/known_hosts + mkdir -p /etc/ansible + echo "${{ secrets.C3LF_INVENTORY_TESTING }}" > /etc/ansible/hosts + + - name: Check ansible version + run: | + ansible --version + + - name: List ansible hosts + run: | + ansible -m ping Andromeda + + - name: Deploy testing + run: | + cd deploy/ansible + ansible-playbook playbooks/deploy-c3lf-sys3.yml diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..480e590 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,21 @@ +on: + pull_request: + push: + +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 diff --git a/.gitignore b/.gitignore index 159c028..9f86c99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ -/vendor + /.idea -Homestead.json -Homestead.yaml .env .local -/public/docs -/public/staticimages -/public/thumbnails -/resources/docs -composer.lock -composer.phar - -.phpunit.result.cache +staticfiles/ +userfiles/ +*.db \ No newline at end of file diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 62d2991..0000000 --- a/.styleci.yml +++ /dev/null @@ -1,9 +0,0 @@ -php: - preset: laravel - enabled: - - alpha_ordered_imports - disabled: - - length_ordered_imports - - unused_use -js: true -css: true diff --git a/README.md b/README.md new file mode 100644 index 0000000..3581cac --- /dev/null +++ b/README.md @@ -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 +`@staging.c3lf.de`** \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php deleted file mode 100644 index ad6e311..0000000 --- a/app/Console/Kernel.php +++ /dev/null @@ -1,29 +0,0 @@ -select('containers.cid', 'name', DB::raw('count(items.iid) as itemCount')) - ->groupBy('containers.cid', 'name')->get(); - } - - static function find($id){ - return Container::leftJoin('items','items.cid','=','containers.cid') - ->select('containers.cid', 'name', DB::raw('count(items.iid) as itemCount')) - ->groupBy('containers.cid', 'name')->where(Container::primaryKey, '=', $id)->first(); - } -} diff --git a/app/Event.php b/app/Event.php deleted file mode 100644 index 2f99288..0000000 --- a/app/Event.php +++ /dev/null @@ -1,27 +0,0 @@ -create($attributes); - } - - - - -} diff --git a/app/Http/Controllers/ContainerController.php b/app/Http/Controllers/ContainerController.php deleted file mode 100644 index 7982612..0000000 --- a/app/Http/Controllers/ContainerController.php +++ /dev/null @@ -1,46 +0,0 @@ -json(Container::all()); - } - - public function showOneContainer($id) - { - return response()->json(Container::find($id)); - } - - public function create(Request $request) - { - $container = Container::create($request->all()); - - return response()->json($container, 201); - } - - public function update($id, Request $request) - { - $container = Container::findOrFail($id); - $container->update($request->all()); - - return response()->json($container, 200); - } - - public function delete($id) - { - Container::findOrFail($id)->delete(); - return response('Deleted Successfully', 200); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php deleted file mode 100644 index 0ccb918..0000000 --- a/app/Http/Controllers/Controller.php +++ /dev/null @@ -1,10 +0,0 @@ -json(Event::all()); - } - - public function showOneEvent($id) - { - return response()->json(Event::find($id)); - } - - public function create(Request $request) - { - $event = Event::create($request->all()); - - return response()->json($event, 201); - } - - public function update($id, Request $request) - { - $event = Event::findOrFail($id); - $event->update($request->all()); - - return response()->json($event, 200); - } - - public function delete($id) - { - Event::findOrFail($id)->delete(); - return response('Deleted Successfully', 200); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php deleted file mode 100644 index 61840db..0000000 --- a/app/Http/Controllers/FileController.php +++ /dev/null @@ -1,32 +0,0 @@ -json(File::all()); - } - - public function showOneFile($id) - { - return response()->json(File::find($id)); - } - - public function create(Request $request) - { - $file = File::create($request->only(['data','iid'])); - return response()->json($file, 201); - } - - public function delete($id) - { - File::findOrFail($id)->delete(); - return response('Deleted Successfully', 200); - } -} diff --git a/app/Http/Controllers/ItemController.php b/app/Http/Controllers/ItemController.php deleted file mode 100644 index 1c94846..0000000 --- a/app/Http/Controllers/ItemController.php +++ /dev/null @@ -1,83 +0,0 @@ -json(Item::all()); - } - - public function showByEvent($event) - { - $eid = Event::where('slug','=',$event)->first()->eid; - $q = Item::byEvent($eid); - return response()->json($q->get()); - } - - public function searchByEvent($event, $query) - { - $eid = Event::where('slug','=',$event)->first()->eid; - $query_tokens = explode(" ",base64_decode ( $query , true)); - $q = Item::byEvent($eid); - foreach ($query_tokens as $token) - if(!empty($token)) - $q = $q->where('items.description','like','%'.$token.'%'); - return response()->json($q->get()); - } - - public function showOneItem($event, $id) - { - $eid = Event::where('slug','=',$event)->first()->eid; - return response()->json(Item::byEvent($eid)->where('uid', '=', $id)->first()); - } - - public function create($event, Request $request) - { - $eid = Event::where('slug','=',$event)->first()->eid; - $newitem = $request->except(['dataImage']); - $newitem['eid'] = "".$eid; - $item = Item::create($newitem); - - if($request->get('dataImage')) { - $file = File::create(array('data' => $request->get('dataImage'), 'iid' => $item['iid'])); - $item['file'] = $file['hash']; - } - - return response()->json($item, 201); - } - - public function update($event, $id, Request $request) - { - $eid = Event::where('slug', $event)->first()->eid; - $item = Item::where('eid', $eid)->where('uid', $id)->first(); - $item->update($request->except(['file', 'box', 'dataImage'])); - - if($request->get('returned')===true){ - $item->update(['returned_at' => DB::raw( 'current_timestamp' )]); - } - - if($request->get('dataImage')) { - $file = File::create(array('data' => $request->get('dataImage'), 'iid' => $item['iid'])); - $item['file'] = $file['hash']; - } - - return response()->json(Item::find($item['uid']), 200); - } - - public function delete($event, $id) - { - $eid = Event::where('slug','=',$event)->first()->eid; - Item::where('eid', $eid)->where('uid', $id)->first()->delete(); - return response()->json(array("status"=>'Deleted Successfully'), 200); - } -} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php deleted file mode 100644 index 361a11e..0000000 --- a/app/Http/Middleware/Authenticate.php +++ /dev/null @@ -1,44 +0,0 @@ -auth = $auth; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @param string|null $guard - * @return mixed - */ - public function handle($request, Closure $next, $guard = null) - { - if ($this->auth->guard($guard)->guest()) { - return response('Unauthorized.', 401); - } - - return $next($request); - } -} diff --git a/app/Http/Middleware/ExampleMiddleware.php b/app/Http/Middleware/ExampleMiddleware.php deleted file mode 100644 index 166581c..0000000 --- a/app/Http/Middleware/ExampleMiddleware.php +++ /dev/null @@ -1,20 +0,0 @@ -withTrashed()->where('eid',$attributes['eid'])->max('uid') + 1; - $attributes['uid'] = $uid; - $item = static::query()->create($attributes); - return Item::find($item->iid); - } - - protected static function extended($columns=Array()){ - return Item::whereNull('returned_at') - ->join('containers','items.cid','=','containers.cid') - ->leftJoin('currentfiles','items.iid','=','currentfiles.iid'); - } - - static function byEvent($eid){ - return Item::extended()->where('eid','=',$eid) - ->select('items.*','currentfiles.hash as file', 'containers.name as box'); - } - - static function all($columns=Array()){ - return Item::extended($columns) - ->select('items.*','currentfiles.hash as file', 'containers.name as box') - ->get(); - } - - static function find($id){ - return Item::extended() - ->select('items.*','currentfiles.hash as file', 'containers.name as box') - ->where('items.iid', '=', $id)->first(); - } -} diff --git a/app/Jobs/ExampleJob.php b/app/Jobs/ExampleJob.php deleted file mode 100644 index 7b65bb4..0000000 --- a/app/Jobs/ExampleJob.php +++ /dev/null @@ -1,26 +0,0 @@ -app['auth']->viaRequest('api', function ($request) { - if ($request->input('api_token')) { - return User::where('api_token', $request->input('api_token'))->first(); - } - }); - } -} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php deleted file mode 100644 index a3d284f..0000000 --- a/app/Providers/EventServiceProvider.php +++ /dev/null @@ -1,19 +0,0 @@ - [ - 'App\Listeners\ExampleListener', - ], - ]; -} diff --git a/app/User.php b/app/User.php deleted file mode 100644 index 0284e3d..0000000 --- a/app/User.php +++ /dev/null @@ -1,32 +0,0 @@ -make( - 'Illuminate\Contracts\Console\Kernel' -); - -exit($kernel->handle(new ArgvInput, new ConsoleOutput)); diff --git a/backup.sh b/backup.sh deleted file mode 100644 index bb4b4ad..0000000 --- a/backup.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash - -#backup.sh /var/www/lfbackend OPTION -#OPTION -# - F FULL BACKUP -# - T Only save images from today -# - I Incremental Backup (Not implemented yet) -# (OPTIONS T and I only apply for images) - -#CRON -#17 * * * * root cd /tmp && /var/www/lfbackend/backup.sh /var/www/lfbackend T -#45 5 * * * root cd /tmp && /var/www/lfbackend/backup.sh /var/www/lfbackend F - -OPTION=$2 - -source $1/.env - -TS=`date +%Y%m%d%H%M%S` - -mysqldump -u $DB_USERNAME -p$DB_PASSWORD -h $DB_HOST $DB_DATABASE > database.sql -if [ "$OPTION" == "T" ] -then - tar -N "`date +%Y-%m-%d`" -zcvf images.tar.gz -C $1/public/staticimages/ . -elif [ "$OPTION" == "I" ] -then - tar -zcvf images.tar.gz -C $1/public/staticimages/ . -else - tar -zcvf images.tar.gz -C $1/public/staticimages/ . -fi - -gzip -f database.sql -tar -cvf backup_${TS}_${OPTION}.tar database.sql.gz images.tar.gz - diff --git a/bootstrap/app.php b/bootstrap/app.php deleted file mode 100644 index 48de537..0000000 --- a/bootstrap/app.php +++ /dev/null @@ -1,102 +0,0 @@ -bootstrap(); - -/* -|-------------------------------------------------------------------------- -| Create The Application -|-------------------------------------------------------------------------- -| -| Here we will load the environment and create the application instance -| that serves as the central piece of this framework. We'll use this -| application as an "IoC" container and router for this framework. -| -*/ - -$app = new Laravel\Lumen\Application( - dirname(__DIR__) -); - -$app->withFacades(); - -$app->withEloquent(); - -/* -|-------------------------------------------------------------------------- -| Register Container Bindings -|-------------------------------------------------------------------------- -| -| Now we will register a few bindings in the service container. We will -| register the exception handler and the console kernel. You may add -| your own bindings here if you like or you can make another file. -| -*/ - -$app->singleton( - Illuminate\Contracts\Debug\ExceptionHandler::class, - App\Exceptions\Handler::class -); - -$app->singleton( - Illuminate\Contracts\Console\Kernel::class, - App\Console\Kernel::class -); - -/* -|-------------------------------------------------------------------------- -| Register Middleware -|-------------------------------------------------------------------------- -| -| Next, we will register the middleware with the application. These can -| be global middleware that run before and after each request into a -| route or middleware that'll be assigned to some specific routes. -| -*/ - -// $app->middleware([ -// App\Http\Middleware\ExampleMiddleware::class -// ]); - -// $app->routeMiddleware([ -// 'auth' => App\Http\Middleware\Authenticate::class, -// ]); - -/* -|-------------------------------------------------------------------------- -| Register Service Providers -|-------------------------------------------------------------------------- -| -| Here we will register all of the application's service providers which -| are used to bind services into the container. Service providers are -| totally optional, so you are not required to uncomment this line. -| -*/ - -// $app->register(App\Providers\AppServiceProvider::class); -// $app->register(App\Providers\AuthServiceProvider::class); -// $app->register(App\Providers\EventServiceProvider::class); -$app->register(\Mpociot\ApiDoc\ApiDocGeneratorServiceProvider::class); -$app->configure('apidoc'); - -/* -|-------------------------------------------------------------------------- -| Load The Application Routes -|-------------------------------------------------------------------------- -| -| Next we will include the routes file so that they can all be added to -| the application. This will provide all of the URLs the application -| can respond to, as well as the controllers that may handle them. -| -*/ - -$app->router->group([ - 'namespace' => 'App\Http\Controllers', -], function ($router) { - require __DIR__.'/../routes/web.php'; -}); - -return $app; diff --git a/composer.json b/composer.json deleted file mode 100644 index 4ee5c32..0000000 --- a/composer.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "laravel/lumen", - "description": "The Laravel Lumen Framework.", - "keywords": ["framework", "laravel", "lumen"], - "license": "MIT", - "type": "project", - "require": { - "php": "^7.2", - "doctrine/dbal": "^2.10", - "laravel/lumen-framework": "^6.0", - "mpociot/laravel-apidoc-generator": "^4.0" - }, - "require-dev": { - "fzaninotto/faker": "^1.4", - "phpunit/phpunit": "^8.0", - "mockery/mockery": "^1.0" - }, - "autoload": { - "classmap": [ - "database/seeds", - "database/factories" - ], - "psr-4": { - "App\\": "app/" - } - }, - "autoload-dev": { - "classmap": [ - "tests/" - ] - }, - "scripts": { - "post-root-package-install": [ - "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" - ] - }, - "config": { - "preferred-install": "dist", - "sort-packages": true, - "optimize-autoloader": true - }, - "minimum-stability": "dev", - "prefer-stable": true -} diff --git a/config/apidoc.php b/config/apidoc.php deleted file mode 100644 index cb46b7f..0000000 --- a/config/apidoc.php +++ /dev/null @@ -1,245 +0,0 @@ - 'static', - - /* - * The router to be used (Laravel or Dingo). - */ - 'router' => 'laravel', - - /* - * The base URL to be used in examples and the Postman collection. - * By default, this will be the value of config('app.url'). - */ - 'base_url' => null, - - /* - * Generate a Postman collection in addition to HTML docs. - * For 'static' docs, the collection will be generated to public/docs/collection.json. - * For 'laravel' docs, it will be generated to storage/app/apidoc/collection.json. - * The `ApiDoc::routes()` helper will add routes for both the HTML and the Postman collection. - */ - 'postman' => [ - /* - * Specify whether the Postman collection should be generated. - */ - 'enabled' => true, - - /* - * The name for the exported Postman collection. Default: config('app.name')." API" - */ - 'name' => null, - - /* - * The description for the exported Postman collection. - */ - 'description' => null, - ], - - /* - * The routes for which documentation should be generated. - * Each group contains rules defining which routes should be included ('match', 'include' and 'exclude' sections) - * and rules which should be applied to them ('apply' section). - */ - 'routes' => [ - [ - /* - * Specify conditions to determine what routes will be parsed in this group. - * A route must fulfill ALL conditions to pass. - */ - 'match' => [ - - /* - * Match only routes whose domains match this pattern (use * as a wildcard to match any characters). - */ - 'domains' => [ - '*', - // 'domain1.*', - ], - - /* - * Match only routes whose paths match this pattern (use * as a wildcard to match any characters). - */ - 'prefixes' => [ - '*', - // 'users/*', - ], - - /* - * Match only routes registered under this version. This option is ignored for Laravel router. - * Note that wildcards are not supported. - */ - 'versions' => [ - 'v1', - ], - ], - - /* - * Include these routes when generating documentation, - * even if they did not match the rules above. - * Note that the route must be referenced by name here (wildcards are supported). - */ - 'include' => [ - // 'users.index', 'healthcheck*' - ], - - /* - * Exclude these routes when generating documentation, - * even if they matched the rules above. - * Note that the route must be referenced by name here (wildcards are supported). - */ - 'exclude' => [ - // 'users.create', 'admin.*' - ], - - /* - * Specify rules to be applied to all the routes in this group when generating documentation - */ - 'apply' => [ - /* - * Specify headers to be added to the example requests - */ - 'headers' => [ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - // 'Authorization' => 'Bearer {token}', - // 'Api-Version' => 'v2', - ], - - /* - * If no @response or @transformer declarations are found for the route, - * we'll try to get a sample response by attempting an API call. - * Configure the settings for the API call here. - */ - 'response_calls' => [ - /* - * API calls will be made only for routes in this group matching these HTTP methods (GET, POST, etc). - * List the methods here or use '*' to mean all methods. Leave empty to disable API calls. - */ - 'methods' => ['GET'], - - /* - * Laravel config variables which should be set for the API call. - * This is a good place to ensure that notifications, emails - * and other external services are not triggered - * during the documentation API calls - */ - 'config' => [ - 'app.env' => 'documentation', - 'app.debug' => false, - // 'service.key' => 'value', - ], - - /* - * Cookies which should be sent with the API call. - */ - 'cookies' => [ - // 'name' => 'value' - ], - - /* - * Query parameters which should be sent with the API call. - */ - 'queryParams' => [ - // 'key' => 'value', - ], - - /* - * Body parameters which should be sent with the API call. - */ - 'bodyParams' => [ - // 'key' => 'value', - ], - ], - ], - ], - ], - - 'strategies' => [ - 'metadata' => [ - \Mpociot\ApiDoc\Extracting\Strategies\Metadata\GetFromDocBlocks::class, - ], - 'urlParameters' => [ - \Mpociot\ApiDoc\Extracting\Strategies\UrlParameters\GetFromUrlParamTag::class, - ], - 'queryParameters' => [ - \Mpociot\ApiDoc\Extracting\Strategies\QueryParameters\GetFromQueryParamTag::class, - ], - 'headers' => [ - \Mpociot\ApiDoc\Extracting\Strategies\RequestHeaders\GetFromRouteRules::class, - ], - 'bodyParameters' => [ - \Mpociot\ApiDoc\Extracting\Strategies\BodyParameters\GetFromBodyParamTag::class, - ], - 'responses' => [ - \Mpociot\ApiDoc\Extracting\Strategies\Responses\UseTransformerTags::class, - \Mpociot\ApiDoc\Extracting\Strategies\Responses\UseResponseTag::class, - \Mpociot\ApiDoc\Extracting\Strategies\Responses\UseResponseFileTag::class, - \Mpociot\ApiDoc\Extracting\Strategies\Responses\UseApiResourceTags::class, - \Mpociot\ApiDoc\Extracting\Strategies\Responses\ResponseCalls::class, - ], - ], - - /* - * Custom logo path. The logo will be copied from this location - * during the generate process. Set this to false to use the default logo. - * - * Change to an absolute path to use your custom logo. For example: - * 'logo' => resource_path('views') . '/api/logo.png' - * - * If you want to use this, please be aware of the following rules: - * - the image size must be 230 x 52 - */ - 'logo' => false, - - /* - * Name for the group of routes which do not have a @group set. - */ - 'default_group' => 'general', - - /* - * Example requests for each endpoint will be shown in each of these languages. - * Supported options are: bash, javascript, php, python - * You can add a language of your own, but you must publish the package's views - * and define a corresponding view for it in the partials/example-requests directory. - * See https://laravel-apidoc-generator.readthedocs.io/en/latest/generating-documentation.html - * - */ - 'example_languages' => [ - 'bash', - 'javascript', - ], - - /* - * Configure how responses are transformed using @transformer and @transformerCollection - * Requires league/fractal package: composer require league/fractal - * - */ - 'fractal' => [ - /* If you are using a custom serializer with league/fractal, - * you can specify it here. - * - * Serializers included with league/fractal: - * - \League\Fractal\Serializer\ArraySerializer::class - * - \League\Fractal\Serializer\DataArraySerializer::class - * - \League\Fractal\Serializer\JsonApiSerializer::class - * - * Leave as null to use no serializer or return a simple JSON. - */ - 'serializer' => null, - ], - - /* - * If you would like the package to generate the same example values for parameters on each run, - * set this to any number (eg. 1234) - * - */ - 'faker_seed' => null, -]; diff --git a/config/database.php b/config/database.php deleted file mode 100644 index 5c44334..0000000 --- a/config/database.php +++ /dev/null @@ -1,42 +0,0 @@ - env('DB_CONNECTION', 'sqlite'), - 'connections' => [ - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DATABASE_URL'), - 'database' => env('DB_DATABASE', database_path('default.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - ], - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DATABASE_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'default_db'), - 'username' => env('DB_USERNAME', 'default_user'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], - 'sqlite_testing' => [ - 'driver' => 'sqlite', - 'database' => ':memory:', - 'prefix' => '', - ] - ], - 'fetch' => PDO::FETCH_CLASS, // Returns DB objects in an array format. - 'migrations' => 'migrations' -]; - -?> diff --git a/container/db/01_init.sql b/container/db/01_init.sql deleted file mode 100644 index 72a43cf..0000000 --- a/container/db/01_init.sql +++ /dev/null @@ -1,6 +0,0 @@ -DROP DATABASE IF EXISTS lostfound; -CREATE DATABASE lostfound; - -CREATE OR REPLACE USER lostfound IDENTIFIED BY 'lostfound'; - -GRANT ALL privileges ON `lostfound`.* TO 'lostfound'; diff --git a/container/web/general.conf b/container/web/general.conf deleted file mode 100644 index 3e00bb2..0000000 --- a/container/web/general.conf +++ /dev/null @@ -1,9 +0,0 @@ -add_header Strict-Transport-Security "max-age=15768000; includeSubDomains;"; -add_header X-Content-Type-Options nosniff; -add_header X-XSS-Protection "1; mode=block"; -add_header X-Robots-Tag none; -add_header X-Download-Options noopen; -add_header X-Permitted-Cross-Domain-Policies none; -add_header Access-Control-Allow-Origin '*'; - -client_max_body_size 50m; diff --git a/container/web/images.conf b/container/web/images.conf deleted file mode 100644 index 1d36242..0000000 --- a/container/web/images.conf +++ /dev/null @@ -1,7 +0,0 @@ -rewrite '^/1/images/([0-9a-fA-F]{32})/?$' /staticimages/$1 last; -rewrite '^/1/thumbs/([0-9a-fA-F]{32})/?$' /thumbnails/$1 last; -# rewrite '^/thumbnails/([0-9a-fA-F]{32})$' /thumbnail.php?id=$1 last; - -location /thumbnails/ { - try_files $uri /thumbnail.php?id=$uri; -} diff --git a/container/web/init.sh b/container/web/init.sh deleted file mode 100644 index 0176b21..0000000 --- a/container/web/init.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e -cd /app -echo "executing COMPOSER UPDATE" -composer update -echo "executing DATABASE MIGRATE" -php artisan migrate --force diff --git a/container/web/location-root.conf b/container/web/location-root.conf deleted file mode 100644 index a8b6892..0000000 --- a/container/web/location-root.conf +++ /dev/null @@ -1,15 +0,0 @@ -location / { - if ($request_method = OPTIONS) { - add_header Content-Length 0; - add_header Content-Type text/plain; - add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; - add_header Access-Control-Allow-Origin '*'; #$http_origin; - add_header Access-Control-Allow-Headers "Authorization, Content-Type"; - add_header Access-Control-Allow-Credentials true; - return 200; - } - - try_files $uri $uri/ /index.php?$query_string; - - -} diff --git a/core/.coveragerc b/core/.coveragerc new file mode 100644 index 0000000..14c1fba --- /dev/null +++ b/core/.coveragerc @@ -0,0 +1,14 @@ +[run] +source = . + +[report] +fail_under = 100 +show_missing = True +skip_covered = True +omit = + */tests/* + */migrations/* + core/asgi.py + core/wsgi.py + core/settings.py + manage.py \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..9fe17bc --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/app/Console/Commands/.gitkeep b/core/.local/.forgit_fordocker similarity index 100% rename from app/Console/Commands/.gitkeep rename to core/.local/.forgit_fordocker diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..f9780e0 --- /dev/null +++ b/core/README.md @@ -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: + +- `/models.py`: Contains the database models for the module. +- `/serializers.py`: Contains the serializers for the module models. +- `/api_.py`: Contains the API views and endpoints for the 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. +- `/tests//test_.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 `/tests//test_.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`. \ No newline at end of file diff --git a/database/migrations/.gitkeep b/core/authentication/__init__.py similarity index 100% rename from database/migrations/.gitkeep rename to core/authentication/__init__.py diff --git a/core/authentication/admin.py b/core/authentication/admin.py new file mode 100644 index 0000000..0024cb0 --- /dev/null +++ b/core/authentication/admin.py @@ -0,0 +1,14 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from authentication.models import ExtendedUser + + +class ExtendedUserAdmin(UserAdmin): + list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_superuser') + search_fields = ('username', 'email', 'first_name', 'last_name') + ordering = ('username',) + filter_horizontal = ('groups', 'user_permissions', 'permissions') + + +admin.site.register(ExtendedUser, ExtendedUserAdmin) diff --git a/core/authentication/api_v2.py b/core/authentication/api_v2.py new file mode 100644 index 0000000..2547b6d --- /dev/null +++ b/core/authentication/api_v2.py @@ -0,0 +1,89 @@ +from rest_framework import routers, viewsets, serializers, permissions +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.authtoken.serializers import AuthTokenSerializer +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.contrib.auth import login +from django.urls import path +from django.dispatch import receiver +from django.db.models.signals import post_save +from django.contrib.auth.models import Group +from knox.models import AuthToken +from knox.views import LoginView as KnoxLoginView + +from authentication.models import ExtendedUser +from authentication.serializers import UserSerializer, GroupSerializer + + +class UserViewSet(viewsets.ModelViewSet): + queryset = ExtendedUser.objects.all() + serializer_class = UserSerializer + + +class GroupViewSet(viewsets.ModelViewSet): + queryset = Group.objects.all() + serializer_class = GroupSerializer + + +@receiver(post_save, sender=ExtendedUser) +def create_auth_token(sender, instance=None, created=False, **kwargs): + if created: + AuthToken.objects.create(user=instance) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def selfUser(request): + serializer = UserSerializer(request.user) + return Response(serializer.data, status=200) + + +@api_view(['POST']) +@permission_classes([]) +@authentication_classes([]) +def registerUser(request): + try: + username = request.data.get('username') + password = request.data.get('password') + email = request.data.get('email') + + errors = {} + if not username: + errors['username'] = 'Username is required' + if not password: + errors['password'] = 'Password is required' + if not email: + errors['email'] = 'Email is required' + if ExtendedUser.objects.filter(email=email).exists(): + errors['email'] = 'Email already exists' + if ExtendedUser.objects.filter(username=username).exists(): + errors['username'] = 'Username already exists' + if errors: + return Response({'errors': errors}, status=400) + user = ExtendedUser.objects.create_user(username, email, password) + return Response({'username': user.username, 'email': user.email}, status=201) + except Exception as e: + return Response({'errors': str(e)}, status=400) + + +class LoginView(KnoxLoginView): + permission_classes = (permissions.AllowAny,) + authentication_classes = () + + def post(self, request, format=None): + serializer = AuthTokenSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + login(request, user) + return super(LoginView, self).post(request, format=None) + + +router = routers.SimpleRouter() +router.register(r'users', UserViewSet, basename='users') +router.register(r'groups', GroupViewSet, basename='groups') + +urlpatterns = router.urls + [ + path('self/', selfUser), + path('login/', LoginView.as_view()), + path('register/', registerUser), +] diff --git a/core/authentication/migrations/0001_initial.py b/core/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..d659962 --- /dev/null +++ b/core/authentication/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 4.2.7 on 2023-12-11 21:10 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('inventory', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ExtendedUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ], + options={ + 'verbose_name': 'Extended user', + 'verbose_name_plural': 'Extended users', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='EventPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.event')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.permission')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'permission', 'event')}, + }, + ), + migrations.AddField( + model_name='extendeduser', + name='permissions', + field=models.ManyToManyField(through='authentication.EventPermission', to='auth.permission'), + ), + migrations.AddField( + model_name='extendeduser', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'), + ), + ] diff --git a/core/authentication/migrations/0002_authtokeneventpermissions_extendedauthtoken_and_more.py b/core/authentication/migrations/0002_authtokeneventpermissions_extendedauthtoken_and_more.py new file mode 100644 index 0000000..383aafd --- /dev/null +++ b/core/authentication/migrations/0002_authtokeneventpermissions_extendedauthtoken_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.7 on 2023-12-11 21:11 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0001_initial'), + ('knox', '0008_remove_authtoken_salt'), + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AuthTokenEventPermissions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.event')), + ('permission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='authentication.eventpermission')), + ], + ), + migrations.CreateModel( + name='ExtendedAuthToken', + fields=[ + ('authtoken_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='knox.authtoken')), + ('permissions', models.ManyToManyField(through='authentication.AuthTokenEventPermissions', to='authentication.eventpermission')), + ], + options={ + 'verbose_name': 'Extended auth token', + 'verbose_name_plural': 'Extended auth tokens', + }, + bases=('knox.authtoken',), + ), + migrations.AddField( + model_name='authtokeneventpermissions', + name='token', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='authentication.extendedauthtoken'), + ), + migrations.AlterUniqueTogether( + name='authtokeneventpermissions', + unique_together={('token', 'permission', 'event')}, + ), + ] diff --git a/core/authentication/migrations/0003_groups.py b/core/authentication/migrations/0003_groups.py new file mode 100644 index 0000000..c8f299d --- /dev/null +++ b/core/authentication/migrations/0003_groups.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2023-11-26 00:16 + +from django.conf import settings +from django.db import migrations +from django.contrib.auth.models import Permission, Group + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authentication', '0002_authtokeneventpermissions_extendedauthtoken_and_more'), + ('tickets', '0001_initial'), + ] + + def create_groups(apps, schema_editor): + admins = Group.objects.create(name='Admin') + orga = Group.objects.create(name='Orga') + team = Group.objects.create(name='Team') + users = Group.objects.create(name='User') + admins.permissions.add(*Permission.objects.all()) + users.permissions.add(*Permission.objects.filter(codename__in= + ['view_item', 'add_item', 'change_item', 'match_item'])) + team.permissions.add(*Permission.objects.filter(codename__in= + ['delete_item', 'view_issuethread', 'add_issuethread', + 'change_issuethread', 'delete_issuethread', 'send_mail']), + *users.permissions.all()) + orga.permissions.add(*Permission.objects.filter(codename__in=['add_event']), + *team.permissions.all()) + + operations = [ + migrations.RunPython(create_groups), + ] diff --git a/core/authentication/migrations/0004_legacy_user.py b/core/authentication/migrations/0004_legacy_user.py new file mode 100644 index 0000000..76f62dd --- /dev/null +++ b/core/authentication/migrations/0004_legacy_user.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authentication', '0003_groups'), + ] + + def create_legacy_user(apps, schema_editor): + ExtendedUser = apps.get_model('authentication', 'ExtendedUser') + ExtendedUser.objects.create_user(settings.LEGACY_USER_NAME, 'mail@' + settings.MAIL_DOMAIN, + settings.LEGACY_USER_PASSWORD) + + operations = [ + migrations.RunPython(create_legacy_user) + ] diff --git a/core/authentication/migrations/0005_alter_eventpermission_event_and_more.py b/core/authentication/migrations/0005_alter_eventpermission_event_and_more.py new file mode 100644 index 0000000..85258af --- /dev/null +++ b/core/authentication/migrations/0005_alter_eventpermission_event_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.7 on 2023-12-13 16:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0001_initial'), + ('authentication', '0004_legacy_user'), + ] + + operations = [ + migrations.AlterField( + model_name='eventpermission', + name='event', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='inventory.event'), + ), + migrations.AlterField( + model_name='eventpermission', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_permissions', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/resources/views/.gitkeep b/core/authentication/migrations/__init__.py similarity index 100% rename from resources/views/.gitkeep rename to core/authentication/migrations/__init__.py diff --git a/core/authentication/models.py b/core/authentication/models.py new file mode 100644 index 0000000..0de8e6a --- /dev/null +++ b/core/authentication/models.py @@ -0,0 +1,62 @@ +from django.db import models +from django.contrib.auth.models import Permission, AbstractUser +from knox.models import AuthToken + +from inventory.models import Event + + +class ExtendedUser(AbstractUser): + permissions = models.ManyToManyField(Permission, through='EventPermission', through_fields=('user', 'permission')) + + class Meta: + verbose_name = 'Extended user' + verbose_name_plural = 'Extended users' + + def get_permissions(self): + if self.is_superuser: + for permission in Permission.objects.all(): + yield "*:" + permission.codename + for permission in self.user_permissions.all(): + yield "*:" + permission.codename + for group in self.groups.all(): + for permission in group.permissions.all(): + yield "*:" + permission.codename + for permission in self.event_permissions.all(): + yield permission.event.slug + ":" + permission.permission.codename + + def has_event_perm(self, event, permission): + if self.is_superuser: + return True + permissions = set(self.get_permissions()) + if "*:" + permission in permissions: + return True + if event.slug + ":" + permission in permissions: + return True + return False + + +class ExtendedAuthToken(AuthToken): + permissions = models.ManyToManyField('EventPermission', through='AuthTokenEventPermissions', + through_fields=('token', 'permission')) + + class Meta: + verbose_name = 'Extended auth token' + verbose_name_plural = 'Extended auth tokens' + + +class EventPermission(models.Model): + user = models.ForeignKey(ExtendedUser, on_delete=models.CASCADE, related_name='event_permissions') + permission = models.ForeignKey(Permission, on_delete=models.CASCADE) + event = models.ForeignKey(Event, on_delete=models.CASCADE, null=True, blank=True) + + class Meta: + unique_together = ('user', 'permission', 'event') + + +class AuthTokenEventPermissions(models.Model): + token = models.ForeignKey(ExtendedAuthToken, on_delete=models.CASCADE) + permission = models.ForeignKey(EventPermission, on_delete=models.CASCADE) + event = models.ForeignKey(Event, on_delete=models.CASCADE) + + class Meta: + unique_together = ('token', 'permission', 'event') diff --git a/core/authentication/serializers.py b/core/authentication/serializers.py new file mode 100644 index 0000000..0581865 --- /dev/null +++ b/core/authentication/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from django.contrib.auth.models import Group + +from authentication.models import ExtendedUser + + +class UserSerializer(serializers.ModelSerializer): + permissions = serializers.SerializerMethodField() + groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name') + + class Meta: + model = ExtendedUser + fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups') + read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups') + + def get_permissions(self, obj): + return list(set(obj.get_permissions())) + + +class GroupSerializer(serializers.ModelSerializer): + permissions = serializers.SerializerMethodField() + members = serializers.SerializerMethodField() + + class Meta: + model = Group + fields = ('id', 'name', 'permissions', 'members') + + def get_permissions(self, obj): + return ["*:" + p.codename for p in obj.permissions.all()] + + def get_members(self, obj): + return [u.username for u in obj.user_set.all()] diff --git a/core/authentication/tests/__init__.py b/core/authentication/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/authentication/tests/v2/__init__.py b/core/authentication/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/authentication/tests/v2/test_permissions.py b/core/authentication/tests/v2/test_permissions.py new file mode 100644 index 0000000..0a00fcd --- /dev/null +++ b/core/authentication/tests/v2/test_permissions.py @@ -0,0 +1,90 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken + +from authentication.models import EventPermission, ExtendedUser +from inventory.models import Event + + +class PermissionsTestCase(TestCase): + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + event1 = Event.objects.create(slug='testevent1', name='testevent1') + event2 = Event.objects.create(slug='testevent2', name='testevent2') + permission1 = Permission.objects.get(codename='view_event') + EventPermission.objects.create(user=self.user, permission=permission1, event=event1) + EventPermission.objects.create(user=self.user, permission=permission1, event=event2) + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.newuser = ExtendedUser.objects.create_user('newuser', 'test', 'test') + self.newuser_token = AuthToken.objects.create(user=self.newuser) + self.newuser_client = Client(headers={'Authorization': 'Token ' + self.newuser_token[1]}) + + def test_user_permissions(self): + """ + Test that a user can only access their own data. + """ + response = self.client.get('/api/2/users/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 3) + self.assertEqual(response.json()[0]['username'], 'legacy_user') + self.assertEqual(response.json()[0]['email'], 'mail@localhost') + self.assertEqual(response.json()[0]['first_name'], '') + self.assertEqual(response.json()[0]['last_name'], '') + self.assertEqual(response.json()[0]['id'], 1) + self.assertEqual(response.json()[1]['username'], 'testuser') + self.assertEqual(response.json()[1]['email'], 'test') + self.assertEqual(response.json()[1]['first_name'], '') + self.assertEqual(response.json()[1]['last_name'], '') + + def test_user_permission(self): + """ + Test that a user can only access their own data. + """ + #ä['add_logentry', 'change_logentry', 'delete_logentry', 'view_logentry', 'add_group', 'change_group', + #ä 'delete_group', 'view_group', 'add_permission', 'change_permission', 'delete_permission', 'view_permission', + #ä 'add_authtokeneventpermissions', 'change_authtokeneventpermissions', 'delete_authtokeneventpermissions', + #ä 'view_authtokeneventpermissions', 'add_eventpermission', 'change_eventpermission', 'delete_eventpermission', + #ä 'view_eventpermission', 'add_extendedauthtoken', 'change_extendedauthtoken', 'delete_extendedauthtoken', + #ä 'view_extendedauthtoken', 'add_extendeduser', 'change_extendeduser', 'delete_extendeduser', + #ä 'view_extendeduser', 'add_contenttype', 'change_contenttype', 'delete_contenttype', 'view_contenttype', + #ä 'add_file', 'change_file', 'delete_file', 'view_file', 'add_container', 'change_container', 'delete_container', + #ä 'view_container', 'add_event', 'change_event', 'delete_event', 'view_event', 'add_item', 'change_item', + #ä 'delete_item', 'match_item', 'view_item', 'add_authtoken', 'change_authtoken', 'delete_authtoken', + #ä 'view_authtoken', 'add_email', 'change_email', 'delete_email', 'view_email', 'add_eventaddress', + #ä 'change_eventaddress', 'delete_eventaddress', 'view_eventaddress', 'add_systemevent', 'change_systemevent', + #ä 'delete_systemevent', 'view_systemevent', 'add_session', 'change_session', 'delete_session', 'view_session', + #ä 'add_comment', 'change_comment', 'delete_comment', 'view_comment', 'add_issuethread', 'change_issuethread', + #ä 'delete_issuethread', 'send_mail', 'view_issuethread', 'add_statechange', 'change_statechange', + #ä 'delete_statechange', 'view_statechange'] + + user = ExtendedUser.objects.create_user('testuser2', 'test', 'test') + user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent1')) + user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2')) + user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1')) + user.save() + #self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1'))) + #self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2'))) + #self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1'))) + #self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent2'))) + + def test_item_api_permissions(self): + """ + Test that a user can only access their own data. + """ + response = self.client.get('/api/2/testevent1/items/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + response = self.client.get('/api/2/testevent2/items/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + response = self.newuser_client.get('/api/2/testevent1/items/') + self.assertEqual(response.status_code, 403) + + response = self.newuser_client.get('/api/2/testevent2/items/') + self.assertEqual(response.status_code, 403) + diff --git a/core/authentication/tests/v2/test_users.py b/core/authentication/tests/v2/test_users.py new file mode 100644 index 0000000..125be9b --- /dev/null +++ b/core/authentication/tests/v2/test_users.py @@ -0,0 +1,183 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission, Group + +from knox.models import AuthToken + +from authentication.models import ExtendedUser, EventPermission +from core import settings +from inventory.models import Event + + +class UserApiTest(TestCase): + + def setUp(self): + self.event = Event.objects.create(name='testevent', slug='testevent') + self.group1 = Group.objects.create(name='testgroup1') + self.group2 = Group.objects.create(name='testgroup2') + self.group1.permissions.add(Permission.objects.get(codename='add_item')) + self.group1.permissions.add(Permission.objects.get(codename='view_item')) + self.group2.permissions.add(Permission.objects.get(codename='view_event')) + self.group2.permissions.add(Permission.objects.get(codename='view_item')) + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(Permission.objects.get(codename='add_event')) + self.user.groups.add(self.group1) + self.user.groups.add(self.group2) + self.user.save() + EventPermission.objects.create(event=self.event, user=self.user, + permission=Permission.objects.get(codename='delete_item')) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_users(self): + response = self.client.get('/api/2/users/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + self.assertEqual(response.json()[0]['username'], settings.LEGACY_USER_NAME) + self.assertEqual(response.json()[0]['email'], 'mail@' + settings.MAIL_DOMAIN) + self.assertEqual(response.json()[0]['first_name'], '') + self.assertEqual(response.json()[0]['last_name'], '') + self.assertEqual(response.json()[0]['id'], 1) + self.assertEqual(response.json()[0]['groups'], []) + self.assertEqual(response.json()[1]['username'], 'testuser') + self.assertEqual(response.json()[1]['email'], 'test') + self.assertEqual(response.json()[1]['first_name'], '') + self.assertEqual(response.json()[1]['last_name'], '') + self.assertEqual(response.json()[1]['id'], 2) + self.assertEqual(response.json()[1]['groups'], ['testgroup1', 'testgroup2']) + + def test_self_user(self): + response = self.client.get('/api/2/self/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['username'], 'testuser') + self.assertEqual(response.json()['email'], 'test') + self.assertEqual(response.json()['first_name'], '') + self.assertEqual(response.json()['last_name'], '') + permissions = response.json()['permissions'] + self.assertEqual(len(permissions), 5) + self.assertTrue('*:add_item' in permissions) + self.assertTrue('*:view_item' in permissions) + self.assertTrue('*:view_event' in permissions) + self.assertTrue('testevent:delete_item' in permissions) + self.assertTrue('*:add_event' in permissions) + + def test_register_user(self): + anonymous = Client() + response = anonymous.post('/api/2/register/', {'username': 'testuser2', 'password': 'test', 'email': 'test2'}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['username'], 'testuser2') + self.assertEqual(response.json()['email'], 'test2') + self.assertEqual(len(ExtendedUser.objects.all()), 3) + self.assertEqual(ExtendedUser.objects.get(username='testuser2').email, 'test2') + self.assertTrue(ExtendedUser.objects.get(username='testuser2').check_password('test')) + + def test_register_user_duplicate(self): + anonymous = Client() + response = anonymous.post('/api/2/register/', {'username': 'testuser', 'password': 'test', 'email': 'test2'}, + content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['errors']['username'], 'Username already exists') + self.assertEqual(len(ExtendedUser.objects.all()), 2) + + def test_register_user_no_username(self): + anonymous = Client() + response = anonymous.post('/api/2/register/', {'password': 'test', 'email': 'test2'}, + content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['errors']['username'], 'Username is required') + self.assertEqual(len(ExtendedUser.objects.all()), 2) + + def test_register_user_no_password(self): + anonymous = Client() + response = anonymous.post('/api/2/register/', {'username': 'testuser2', 'email': 'test2'}, + content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['errors']['password'], 'Password is required') + self.assertEqual(len(ExtendedUser.objects.all()), 2) + + def test_register_user_no_email(self): + anonymous = Client() + response = anonymous.post('/api/2/register/', {'username': 'testuser2', 'password': 'test'}, + content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['errors']['email'], 'Email is required') + self.assertEqual(len(ExtendedUser.objects.all()), 2) + + def test_register_user_duplicate_email(self): + anonymous = Client() + response = anonymous.post('/api/2/register/', {'username': 'testuser2', 'password': 'test', 'email': 'test'}, + content_type='application/json') + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['errors']['email'], 'Email already exists') + self.assertEqual(len(ExtendedUser.objects.all()), 2) + + def test_get_token(self): + anonymous = Client() + response = anonymous.post('/api/2/login/', {'username': 'testuser', 'password': 'test'}, + content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertTrue('token' in response.json()) + + def test_legacy_user(self): + response = self.client.get('/api/2/users/1/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['username'], settings.LEGACY_USER_NAME) + self.assertEqual(response.json()['email'], 'mail@' + settings.MAIL_DOMAIN) + self.assertEqual(response.json()['first_name'], '') + self.assertEqual(response.json()['last_name'], '') + self.assertEqual(response.json()['id'], 1) + + def test_get_legacy_user_token(self): + anonymous = Client() + response = anonymous.post('/api/2/login/', { + 'username': settings.LEGACY_USER_NAME, 'password': settings.LEGACY_USER_PASSWORD}, + content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertTrue('token' in response.json()) + + +class GroupApiTest(TestCase): + + def setUp(self): + self.event = Event.objects.create(name='testevent', slug='testevent') + # Admin, Orga, Team, User are created by default + self.group1 = Group.objects.create(name='testgroup1') + self.group2 = Group.objects.create(name='testgroup2') + self.group1.permissions.add(Permission.objects.get(codename='add_item')) + self.group1.permissions.add(Permission.objects.get(codename='view_item')) + self.group2.permissions.add(Permission.objects.get(codename='view_event')) + self.group2.permissions.add(Permission.objects.get(codename='view_item')) + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(Permission.objects.get(codename='add_event')) + self.user.groups.add(self.group1) + self.user.groups.add(self.group2) + self.user.save() + EventPermission.objects.create(event=self.event, user=self.user, + permission=Permission.objects.get(codename='delete_item')) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_groups(self): + response = self.client.get('/api/2/groups/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 6) + self.assertEqual(response.json()[0]['name'], 'Admin') + self.assertEqual(response.json()[1]['name'], 'Orga') + self.assertEqual(response.json()[2]['name'], 'Team') + self.assertEqual(response.json()[3]['name'], 'User') + self.assertEqual(response.json()[4]['name'], 'testgroup1') + self.assertEqual(response.json()[5]['name'], 'testgroup2') + + def test_group(self): + response = self.client.get('/api/2/groups/5/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['name'], 'testgroup1') + permissions = response.json()['permissions'] + self.assertEqual(len(permissions), 2) + self.assertTrue('*:add_item' in permissions) + self.assertTrue('*:view_item' in permissions) + members = response.json()['members'] + self.assertEqual(len(members), 1) + self.assertEqual(members[0], 'testuser') diff --git a/core/core/__init__.py b/core/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/core/asgi.py b/core/core/asgi.py new file mode 100644 index 0000000..9d7fde2 --- /dev/null +++ b/core/core/asgi.py @@ -0,0 +1,66 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator +from django.core.asgi import get_asgi_application +from notify_sessions.routing import websocket_urlpatterns + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +django_asgi_app = get_asgi_application() + + +class TokenAuthMiddleware: + """ + Token authorization middleware for Django Channels 2 + """ + + def __init__(self, inner): + self.inner = inner + + def __call__(self, scope): + import base64 + headers = dict(scope['headers']) + if b'authorization' in headers: + try: + token_name, token_key = headers[b'authorization'].decode().split() + if token_name == 'Basic': + b64 = base64.b64decode(token_key) + user = b64.decode().split(':')[0] + password = b64.decode().split(':')[1] + print(user, password) + else: + print("Token name is not Basic") + scope['user'] = None + except: + print("Token is not valid") + scope['user'] = None + else: + print("Token is not in headers") + scope['user'] = None + + +TokenAuthMiddlewareStack = lambda inner: TokenAuthMiddleware(AuthMiddlewareStack(inner)) + +websocket_asgi_app = AllowedHostsOriginValidator( + AuthMiddlewareStack( + URLRouter( + websocket_urlpatterns + ) + ) +) + +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": websocket_asgi_app, +}) diff --git a/core/core/globals.py b/core/core/globals.py new file mode 100644 index 0000000..d84a7a0 --- /dev/null +++ b/core/core/globals.py @@ -0,0 +1,29 @@ +import asyncio +import logging +import signal + +loop = asyncio.get_event_loop() + + +def create_task(coro): + global loop + loop.create_task(coro) + + +async def shutdown(sig, loop): + log = logging.getLogger() + log.info(f"Received exit signal {sig.name}...") + tasks = [t for t in asyncio.all_tasks() if t is not + asyncio.current_task()] + [task.cancel() for task in tasks] + log.info(f"Cancelling {len(tasks)} outstanding tasks") + await asyncio.wait_for(loop.shutdown_asyncgens(), timeout=10) + loop.stop() + log.info("Shutdown complete.") + + +def init_loop(): + global loop + loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(shutdown(signal.SIGTERM, loop))) + loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(shutdown(signal.SIGINT, loop))) + return loop diff --git a/core/core/metrics.py b/core/core/metrics.py new file mode 100644 index 0000000..d973b0d --- /dev/null +++ b/core/core/metrics.py @@ -0,0 +1,40 @@ +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()) diff --git a/core/core/settings.py b/core/core/settings.py new file mode 100644 index 0000000..805a27b --- /dev/null +++ b/core/core/settings.py @@ -0,0 +1,235 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 4.2.7. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" +import os +import sys + +import dotenv +from pathlib import Path + + +def truthy_str(s): + return s.lower() in ['true', '1', 't', 'y', 'yes', 'yeah', 'yup', 'certainly', 'sure', 'positive', 'uh-huh', '👍'] + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +dotenv.load_dotenv(BASE_DIR / '.env') + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = truthy_str(os.getenv('DEBUG_MODE_ACTIVE', 'False')) + +PRIMARY_HOST = os.getenv('HTTP_HOST', 'localhost') + +ALLOWED_HOSTS = [PRIMARY_HOST] + +MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost') + +CSRF_TRUSTED_ORIGINS = ["https://" + host for host in ALLOWED_HOSTS] + +LEGACY_USER_NAME = os.getenv('LEGACY_API_USER', 'legacy_user') +LEGACY_USER_PASSWORD = os.getenv('LEGACY_API_PASSWORD', 'legacy_password') + +SYSTEM3_VERSION = "0.0.0-dev.0" + +ACTIVE_SPAM_TRAINING = truthy_str(os.getenv('ACTIVE_SPAM_TRAINING', 'False')) + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_extensions', + 'django_prometheus', + 'rest_framework', + 'knox', + 'drf_yasg', + 'channels', + 'authentication', + 'files', + 'tickets', + 'inventory', + 'mail', + 'notify_sessions', +] + +REST_FRAMEWORK = { + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', + 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.DjangoModelPermissions'), +} + +AUTH_USER_MODEL = 'authentication.ExtendedUser' + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'api_key': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization' + } + }, + 'USE_SESSION_AUTH': False, + 'JSON_EDITOR': True, + 'DEFAULT_INFO': 'core.urls.openapi_info', +} + +MIDDLEWARE = [ + 'django_prometheus.middleware.PrometheusBeforeMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django_prometheus.middleware.PrometheusAfterMiddleware', +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +ASGI_APPLICATION = 'core.asgi.application' + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +if os.getenv('DB_HOST') is not None: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'HOST': os.getenv('DB_HOST', 'localhost'), + 'PORT': os.getenv('DB_PORT', '3306'), + 'NAME': os.getenv('DB_NAME', 'system3'), + 'USER': os.getenv('DB_USER', 'system3'), + 'PASSWORD': os.getenv('DB_PASSWORD', 'system3'), + 'OPTIONS': { + '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:', + } + } + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_ROOT = os.getenv('STATIC_ROOT', 'staticfiles') +STATIC_URL = '/static/' + +MEDIA_ROOT = os.getenv('MEDIA_ROOT', 'userfiles') +MEDIA_URL = '/media/' + +STORAGES = { + 'default': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + 'OPTIONS': { + 'base_url': MEDIA_URL, + 'location': BASE_DIR / MEDIA_ROOT + }, + }, + 'staticfiles': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + 'OPTIONS': { + 'base_url': STATIC_URL, + 'location': BASE_DIR / STATIC_ROOT + }, + }, +} +DATA_UPLOAD_MAX_MEMORY_SIZE = 1024 * 1024 * 128 # 128 MB + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels_redis.core.RedisChannelLayer', + 'CONFIG': { + 'hosts': [(os.getenv('REDIS_HOST', 'localhost'), 6379)], + }, + } + +} + +PROMETHEUS_METRIC_NAMESPACE = 'c3lf' + +TEST_RUNNER = 'core.test_runner.FastTestRunner' diff --git a/core/core/test_runner.py b/core/core/test_runner.py new file mode 100644 index 0000000..bd7f9eb --- /dev/null +++ b/core/core/test_runner.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.test.runner import DiscoverRunner + + +class FastTestRunner(DiscoverRunner): + def setup_test_environment(self): + super(FastTestRunner, self).setup_test_environment() + # Don't write files + settings.STORAGES = { + 'default': { + 'BACKEND': 'django.core.files.storage.InMemoryStorage', + 'OPTIONS': { + 'base_url': '/media/', + 'location': '', + }, + }, + } + # Bonus: Use a faster password hasher. This REALLY helps. + settings.PASSWORD_HASHERS = ( + 'django.contrib.auth.hashers.MD5PasswordHasher', + ) + + settings.CHANNEL_LAYERS = { + 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer' + } + } + settings.DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + } diff --git a/core/core/urls.py b/core/core/urls.py new file mode 100644 index 0000000..2386891 --- /dev/null +++ b/core/core/urls.py @@ -0,0 +1,35 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +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')), + path('api/2/', include('files.api_v2')), + path('media/2/', include('files.media_v2')), + path('api/2/', include('tickets.api_v2')), + path('api/2/', include('mail.api_v2')), + path('api/2/', include('notify_sessions.api_v2')), + path('api/2/', include('authentication.api_v2')), + path('api/', get_info), + path('', include('django_prometheus.urls')), +] diff --git a/core/core/version.py b/core/core/version.py new file mode 100644 index 0000000..64d3bde --- /dev/null +++ b/core/core/version.py @@ -0,0 +1,15 @@ +from rest_framework.decorators import api_view, permission_classes, authentication_classes +from rest_framework.response import Response + +from .settings import SYSTEM3_VERSION + + +@api_view(['GET']) +@permission_classes([]) +@authentication_classes([]) +def get_info(request): + return Response({ + "framework_version": SYSTEM3_VERSION, + "api_min_version": "1.0", + "api_max_version": "1.0", + }) diff --git a/core/files/__init__.py b/core/files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/admin.py b/core/files/admin.py new file mode 100644 index 0000000..3584ed5 --- /dev/null +++ b/core/files/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from files.models import File + + +class FileAdmin(admin.ModelAdmin): + pass + + +admin.site.register(File, FileAdmin) \ No newline at end of file diff --git a/core/files/api_v2.py b/core/files/api_v2.py new file mode 100644 index 0000000..a0962f0 --- /dev/null +++ b/core/files/api_v2.py @@ -0,0 +1,24 @@ +from rest_framework import serializers, viewsets, routers + +from files.models import File + + +class FileSerializer(serializers.ModelSerializer): + data = serializers.CharField(max_length=1000000, write_only=True) + + class Meta: + model = File + fields = ['hash', 'data'] + read_only_fields = ['hash'] + + +class FileViewSet(viewsets.ModelViewSet): + serializer_class = FileSerializer + queryset = File.objects.all() + lookup_field = 'hash' + + +router = routers.SimpleRouter() +router.register(r'files', FileViewSet, basename='files') + +urlpatterns = router.urls diff --git a/core/files/media_v2.py b/core/files/media_v2.py new file mode 100644 index 0000000..00161b3 --- /dev/null +++ b/core/files/media_v2.py @@ -0,0 +1,96 @@ +from datetime import datetime, timedelta + +import os +from django.http import HttpResponse +from django.urls import path +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from core.settings import MEDIA_ROOT +from files.models import File +from mail.models import EmailAttachment + + +@swagger_auto_schema(method='GET', auto_schema=None) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def media_urls(request, hash): + try: + if request.META.get('HTTP_IF_NONE_MATCH') and request.META.get('HTTP_IF_NONE_MATCH') == hash: + return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) + + file = File.objects.filter(hash=hash).first() + attachment = EmailAttachment.objects.filter(hash=hash).first() + file = file if file else attachment + if not file: + return Response(status=status.HTTP_404_NOT_FOUND) + hash_path = file.file + return HttpResponse(status=status.HTTP_200_OK, + content_type=file.mime_type, + headers={ + 'X-Accel-Redirect': f'/redirect_media/{hash_path}', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=31536000, private, immutable', + 'Expires': datetime.utcnow() + timedelta(days=365), + 'Age': 0, + 'ETag': file.hash, + }) + except FileNotFoundError: + return Response(status=status.HTTP_404_NOT_FOUND) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except EmailAttachment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +@swagger_auto_schema(method='GET', auto_schema=None) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def thumbnail_urls(request, size, hash): + if size not in [32, 64, 256]: + return Response(status=status.HTTP_404_NOT_FOUND) + if request.META.get('HTTP_IF_NONE_MATCH') and request.META.get('HTTP_IF_NONE_MATCH') == hash + "_" + str(size): + return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) + try: + file = File.objects.filter(hash=hash).first() + attachment = EmailAttachment.objects.filter(hash=hash).first() + file = file if file else attachment + if not file: + return Response(status=status.HTTP_404_NOT_FOUND) + hash_path = file.file + if not os.path.exists(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}'): + from PIL import Image + image = Image.open(file.file) + image.thumbnail((size, size)) + rgb_image = image.convert('RGB') + thumb_dir = os.path.dirname(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}') + if not os.path.exists(thumb_dir): + os.makedirs(thumb_dir) + rgb_image.save(MEDIA_ROOT + f'/thumbnails/{size}/{hash_path}', 'jpeg', quality=90) + + return HttpResponse(status=status.HTTP_200_OK, + content_type="image/jpeg", + headers={ + 'X-Accel-Redirect': f'/redirect_thumbnail/{size}/{hash_path}', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'max-age=31536000, private, immutable', + 'Expires': datetime.utcnow() + timedelta(days=365), + 'Age': 0, + 'ETag': file.hash + "_" + str(size), + }) + + except FileNotFoundError: + return Response(status=status.HTTP_404_NOT_FOUND) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except EmailAttachment.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +urlpatterns = [ + path('//', thumbnail_urls), + path('/', media_urls), +] diff --git a/core/files/migrations/0001_initial.py b/core/files/migrations/0001_initial.py new file mode 100644 index 0000000..2c15fff --- /dev/null +++ b/core/files/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.7 on 2023-12-09 02:13 + +from django.db import migrations, models +import django.db.models.deletion +import files.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('inventory', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='File', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('file', models.ImageField(upload_to=files.models.hash_upload)), + ('mime_type', models.CharField(max_length=255)), + ('hash', models.CharField(max_length=64, unique=True)), + ('item', models.ForeignKey(blank=True, db_column='iid', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='files', to='inventory.item')), + ], + ), + ] diff --git a/core/files/migrations/0002_alter_file_file.py b/core/files/migrations/0002_alter_file_file.py new file mode 100644 index 0000000..6a0c33e --- /dev/null +++ b/core/files/migrations/0002_alter_file_file.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.7 on 2024-01-10 19:04 + +from django.db import migrations, models +import files.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='file', + name='file', + field=models.FileField(upload_to=files.models.hash_upload), + ), + ] diff --git a/core/files/migrations/0003_ensure_creation_date.py b/core/files/migrations/0003_ensure_creation_date.py new file mode 100644 index 0000000..63e5760 --- /dev/null +++ b/core/files/migrations/0003_ensure_creation_date.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.7 on 2024-11-21 22:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('files', '0002_alter_file_file'), + ] + + def set_creation_date(apps, schema_editor): + File = apps.get_model('files', 'File') + for file in File.objects.all(): + if file.created_at is None: + if not file.item.created_at is None: + file.created_at = file.item.created_at + else: + file.created_at = max(File.objects.filter( + id__lt=file.id, created_at__isnull=False).values_list('created_at', flat=True)) + file.save() + + operations = [ + migrations.RunPython(set_creation_date), + ] diff --git a/core/files/migrations/__init__.py b/core/files/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/models.py b/core/files/models.py new file mode 100644 index 0000000..a8eb775 --- /dev/null +++ b/core/files/models.py @@ -0,0 +1,80 @@ +from django.core.files.base import ContentFile +from django.db import models, IntegrityError + +from inventory.models import Item + + +def hash_upload(instance, filename): + return f"{instance.hash[:2]}/{instance.hash[2:4]}/{instance.hash[4:6]}/{instance.hash[6:]}" + + +class FileManager(models.Manager): + + def __file_data_helper(self, **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') + 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: + obj = super().create(**kwargs) + obj.file.save(content=kwargs['file'], name=kwargs['hash']) + return obj, True + + def create(self, **kwargs): + kwargs = self.__file_data_helper(**kwargs) + if not self.filter(hash=kwargs['hash']).exists(): + obj = super().create(**kwargs) + obj.file.save(content=kwargs['file'], name=kwargs['hash']) + return obj + else: + raise IntegrityError('File with this hash already exists') + + +class AbstractFile(models.Model): + created_at = models.DateTimeField(blank=True, null=True) + updated_at = models.DateTimeField(blank=True, null=True) + deleted_at = models.DateTimeField(blank=True, null=True) + file = models.FileField(upload_to=hash_upload) + mime_type = models.CharField(max_length=255, null=False, blank=False) + hash = models.CharField(max_length=64, null=False, blank=False, unique=True) + + objects = FileManager() + + def save(self, *args, **kwargs): + from django.utils import timezone + if not self.created_at: + self.created_at = timezone.now() + self.updated_at = timezone.now() + super().save(*args, **kwargs) + + class Meta: + abstract = True + + +class File(AbstractFile): + item = models.ForeignKey(Item, models.CASCADE, db_column='iid', null=True, blank=True, related_name='files') + + def __str__(self): + return self.hash diff --git a/core/files/tests/__init__.py b/core/files/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests/v2/__init__.py b/core/files/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/files/tests/v2/test_files.py b/core/files/tests/v2/test_files.py new file mode 100644 index 0000000..dedd647 --- /dev/null +++ b/core/files/tests/v2/test_files.py @@ -0,0 +1,55 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission + +from authentication.models import ExtendedUser +from files.models import File +from inventory.models import Event, Container, Item +from knox.models import AuthToken + + +class FileTestCase(TestCase): + + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.event = Event.objects.create(slug='EVENT', name='Event') + self.box = Container.objects.create(name='BOX') + + def test_list_files(self): + import base64 + item = File.objects.create(data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + response = self.client.get('/api/2/files/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()[0]['hash'], item.hash) + self.assertEqual(len(response.json()[0]['hash']), 64) + + def test_one_file(self): + import base64 + item = File.objects.create(data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + response = self.client.get(f'/api/2/files/{item.hash}/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['hash'], item.hash) + self.assertEqual(len(response.json()['hash']), 64) + + def test_create_file(self): + import base64 + Item.objects.create(container=self.box, event=self.event, description='1') + item = Item.objects.create(container=self.box, event=self.event, description='2') + response = self.client.post('/api/2/files/', + {'data': "data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(len(response.json()['hash']), 64) + + def test_delete_file(self): + import base64 + item = Item.objects.create(container=self.box, event=self.event, description='1') + File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8')) + self.assertEqual(len(File.objects.all()), 2) + response = self.client.delete(f'/api/2/files/{file.hash}/') + self.assertEqual(response.status_code, 204) diff --git a/core/helper.py b/core/helper.py new file mode 100644 index 0000000..ae3c53b --- /dev/null +++ b/core/helper.py @@ -0,0 +1,30 @@ +import asyncio +import logging +import signal + +loop = None + + +def create_task(coro): + global loop + loop.create_task(coro) + + +async def shutdown(sig, loop): + log = logging.getLogger() + log.info(f"Received exit signal {sig.name}...") + tasks = [t for t in asyncio.all_tasks() if t is not + asyncio.current_task()] + [task.cancel() for task in tasks] + log.info(f"Cancelling {len(tasks)} outstanding tasks") + await asyncio.wait_for(loop.shutdown_asyncgens(), timeout=10) + loop.stop() + log.info("Shutdown complete.") + + +def init_loop(): + global loop + loop = asyncio.get_event_loop() + loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(shutdown(signal.SIGTERM, loop))) + loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(shutdown(signal.SIGINT, loop))) + return loop diff --git a/core/inventory/__init__.py b/core/inventory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/inventory/admin.py b/core/inventory/admin.py new file mode 100644 index 0000000..24cd14a --- /dev/null +++ b/core/inventory/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from inventory.models import Item, Container, ItemPlacement, Comment, Event + + +class ItemAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Item, ItemAdmin) + + +class ContainerAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Container, ContainerAdmin) + + +class EventAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Event, EventAdmin) + + +class ItemPlacementAdmin(admin.ModelAdmin): + pass + + +admin.site.register(ItemPlacement, ItemPlacementAdmin) + + +class CommentAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Comment, CommentAdmin) diff --git a/core/inventory/api_v2.py b/core/inventory/api_v2.py new file mode 100644 index 0000000..04c1722 --- /dev/null +++ b/core/inventory/api_v2.py @@ -0,0 +1,205 @@ +from django.urls import re_path +from django.contrib.auth.decorators import permission_required +from rest_framework import routers, viewsets, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from inventory.models import Event, Container, Item, Comment +from inventory.serializers import EventSerializer, ContainerSerializer, CommentSerializer, ItemSerializer, \ + SearchResultSerializer + +from base64 import b64decode + + +class EventViewSet(viewsets.ModelViewSet): + serializer_class = EventSerializer + queryset = Event.objects.all() + permission_classes = [] + + +class ContainerViewSet(viewsets.ModelViewSet): + serializer_class = ContainerSerializer + queryset = Container.objects.all() + + +class ItemViewSet(viewsets.ModelViewSet): + serializer_class = ItemSerializer + + def prefetch_queryset(self, queryset): + serializer = self.get_serializer_class() + if hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'prefetch_related_fields'): + queryset = queryset.prefetch_related(*serializer.Meta.prefetch_related_fields) + return queryset + + def get_queryset(self): + queryset = Item.objects.all() + return self.prefetch_queryset(queryset) + + +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} + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def search_items(request, event_slug, query): + try: + event = Event.objects.get(slug=event_slug) + if not request.user.has_event_perm(event, 'view_item'): + return Response(status=403) + items = filter_items(Item.objects.filter(event=event), b64decode(query).decode('utf-8')) + return Response(SearchResultSerializer(items, many=True).data) + except Event.DoesNotExist: + return Response(status=404) + + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +def item(request, event_slug): + vs = ItemViewSet() + try: + event = None + if event_slug != 'none': + event = Event.objects.get(slug=event_slug) + if request.method == 'GET': + if not request.user.has_event_perm(event, 'view_item'): + return Response(status=403) + return Response(ItemSerializer(vs.prefetch_queryset(Item.objects.filter(event=event)), many=True).data) + elif request.method == 'POST': + if not request.user.has_event_perm(event, 'add_item'): + return Response(status=403) + validated_data = ItemSerializer(data=request.data) + if validated_data.is_valid(): + validated_data.save(event=event) + return Response(validated_data.data, status=201) + return Response(status=400) + except Event.DoesNotExist: + return Response(status=404) + except KeyError: + return Response(status=400) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@permission_required('tickets.add_comment', raise_exception=True) +def add_comment(request, event_slug, id): + event = None + if event_slug != 'none': + event = Event.objects.get(slug=event_slug) + item = Item.objects.get(event=event, id=id) + if not request.user.has_event_perm(event, 'view_item'): + return Response(status=403) + if 'comment' not in request.data or request.data['comment'] == '': + return Response({'status': 'error', 'message': 'missing comment'}, status=status.HTTP_400_BAD_REQUEST) + comment = Comment.objects.create( + item=item, + comment=request.data['comment'], + ) + return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED) + + +@api_view(['GET', 'PUT', 'DELETE', 'PATCH']) +@permission_classes([IsAuthenticated]) +def item_by_id(request, event_slug, id): + try: + event = Event.objects.get(slug=event_slug) + item = Item.objects.get(event=event, id=id) + if request.method == 'GET': + if not request.user.has_event_perm(event, 'view_item'): + return Response(status=403) + return Response(ItemSerializer(item).data) + elif request.method == 'PUT': + if not request.user.has_event_perm(event, 'change_item'): + return Response(status=403) + validated_data = ItemSerializer(item, data=request.data) + if validated_data.is_valid(): + validated_data.save() + return Response(validated_data.data) + return Response(validated_data.errors, status=400) + elif request.method == 'PATCH': + if not request.user.has_event_perm(event, 'change_item'): + return Response(status=403) + validated_data = ItemSerializer(item, data=request.data, partial=True) + if validated_data.is_valid(): + validated_data.save() + return Response(validated_data.data) + return Response(validated_data.errors, status=400) + elif request.method == 'DELETE': + if not request.user.has_event_perm(event, 'delete_item'): + return Response(status=403) + item.delete() + return Response(status=204) + except Item.DoesNotExist: + return Response(status=404) + except Event.DoesNotExist: + return Response(status=404) + + +router = routers.SimpleRouter() +router.register(r'events', EventViewSet, basename='events') +router.register(r'boxes', ContainerViewSet, basename='boxes') +router.register(r'box', ContainerViewSet, basename='boxes') + +urlpatterns = router.urls + [ + re_path(r'^(?P[\w-]+)/items/$', item, name='item'), + re_path(r'^(?P[\w-]+)/items/(?P[-A-Za-z0-9+/]*={0,3})/$', search_items, name='search_items'), + re_path(r'^(?P[\w-]+)/item/$', item, name='item'), + re_path(r'^(?P[\w-]+)/item/(?P\d+)/comment/$', add_comment, name='add_comment'), + re_path(r'^(?P[\w-]+)/item/(?P\d+)/$', item_by_id, name='item_by_id'), +] diff --git a/core/inventory/migrations/0001_initial.py b/core/inventory/migrations/0001_initial.py new file mode 100644 index 0000000..bd08ef3 --- /dev/null +++ b/core/inventory/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.7 on 2023-11-18 11:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Container', + fields=[ + ('cid', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Event', + fields=[ + ('eid', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('slug', models.CharField(max_length=255, unique=True)), + ('start', models.DateTimeField(blank=True, null=True)), + ('end', models.DateTimeField(blank=True, null=True)), + ('pre_start', models.DateTimeField(blank=True, null=True)), + ('post_end', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Item', + fields=[ + ('iid', models.AutoField(primary_key=True, serialize=False)), + ('uid', models.IntegerField()), + ('description', models.TextField()), + ('returned_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('container', models.ForeignKey(db_column='cid', on_delete=django.db.models.deletion.CASCADE, to='inventory.container')), + ('event', models.ForeignKey(db_column='eid', on_delete=django.db.models.deletion.CASCADE, to='inventory.event')), + ], + options={ + 'unique_together': {('uid', 'event')}, + }, + ), + ] diff --git a/core/inventory/migrations/0002_container_deleted_at_container_is_deleted_and_more.py b/core/inventory/migrations/0002_container_deleted_at_container_is_deleted_and_more.py new file mode 100644 index 0000000..28523a7 --- /dev/null +++ b/core/inventory/migrations/0002_container_deleted_at_container_is_deleted_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2023-11-20 11:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='container', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='container', + name='is_deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='item', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='item', + name='is_deleted', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/inventory/migrations/0003_alter_item_options.py b/core/inventory/migrations/0003_alter_item_options.py new file mode 100644 index 0000000..e20b525 --- /dev/null +++ b/core/inventory/migrations/0003_alter_item_options.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-01-07 18:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0002_container_deleted_at_container_is_deleted_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='item', + options={'permissions': [('match_item', 'Can match item')]}, + ), + ] diff --git a/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py b/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py new file mode 100644 index 0000000..b5fd81a --- /dev/null +++ b/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2024-01-22 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0003_alter_item_options'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='item', + name='created_at', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/core/inventory/migrations/0005_rename_cid_container_id_rename_eid_event_id_and_more.py b/core/inventory/migrations/0005_rename_cid_container_id_rename_eid_event_id_and_more.py new file mode 100644 index 0000000..fcd4b8d --- /dev/null +++ b/core/inventory/migrations/0005_rename_cid_container_id_rename_eid_event_id_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.7 on 2024-11-19 22:56 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_alter_event_created_at_alter_item_created_at'), + ] + + operations = [ + migrations.RenameField( + model_name='container', + old_name='cid', + new_name='id', + ), + migrations.RenameField( + model_name='event', + old_name='eid', + new_name='id', + ), + migrations.RenameField( + model_name='item', + old_name='iid', + new_name='id', + ), + migrations.RenameField( + model_name='item', + old_name='uid', + new_name='uid_deprecated', + ), + migrations.AlterUniqueTogether( + name='item', + unique_together=set(), + ), + migrations.AlterField( + model_name='item', + name='container', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.container'), + ), + migrations.AlterField( + model_name='item', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.event'), + ), + migrations.AlterUniqueTogether( + name='item', + unique_together={('uid_deprecated', 'event')}, + ), + ] diff --git a/core/inventory/migrations/0006_alter_event_table.py b/core/inventory/migrations/0006_alter_event_table.py new file mode 100644 index 0000000..2fa421a --- /dev/null +++ b/core/inventory/migrations/0006_alter_event_table.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-11-20 01:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0005_rename_cid_container_id_rename_eid_event_id_and_more'), + ] + + operations = [ + migrations.AlterModelTable( + name='event', + table='common_event', + ), + ] diff --git a/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py b/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py new file mode 100644 index 0000000..918f636 --- /dev/null +++ b/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 4.2.7 on 2024-11-23 15:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('inventory', '0006_alter_event_table'), + ] + + def set_initial_container(apps, schema_editor): + Item = apps.get_model('inventory', 'Item') + for item in Item.objects.all(): + item.container_history.get_or_create(container=item.container_old) + item.save() + + operations = [ + migrations.RenameField( + model_name='item', + old_name='container', + new_name='container_old', + ), + migrations.CreateModel( + name='ItemPlacement', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('container', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_history', + to='inventory.container')), + ('item', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='container_history', + to='inventory.item')), + ], + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('comment', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', + to='inventory.item')), + ], + ), + migrations.RunPython(set_initial_container), + migrations.RemoveField( + model_name='item', + name='container_old', + ), + ] diff --git a/core/inventory/migrations/__init__.py b/core/inventory/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/inventory/models.py b/core/inventory/models.py new file mode 100644 index 0000000..c782bcb --- /dev/null +++ b/core/inventory/models.py @@ -0,0 +1,125 @@ +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 + + +class ItemManager(SoftDeleteManager): + + def create(self, **kwargs): + container = kwargs.pop('container') + if 'uid_deprecated' in kwargs: + raise ValueError('uid_deprecated must not be set manually') + uid_deprecated = Item.all_objects.filter(event=kwargs['event']).count() + 1 + kwargs['uid_deprecated'] = uid_deprecated + item = super().create(**kwargs) + item.container = container + return item + + def get_queryset(self): + return super().get_queryset().filter(returned_at__isnull=True) + + +class Item(SoftDeleteModel): + id = models.AutoField(primary_key=True) + uid_deprecated = models.IntegerField() + description = models.TextField() + event = models.ForeignKey('Event', models.CASCADE) + returned_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(null=True, auto_now_add=True) + updated_at = models.DateTimeField(blank=True, null=True) + + @property + def container(self): + try: + history = sorted(self.container_history.all(), key=lambda x: x.timestamp, reverse=True) + if history: + return history[0].container + else: + return None + except AttributeError: + return None + + @container.setter + def container(self, value): + if self.container == value: + return + self.container_history.create(container=value) + + @property + def related_issues(self): + groups = groupby(self.issue_relation_changes.all(), lambda rel: rel.issue_thread.id) + return [sorted(v, key=lambda r: r.timestamp)[0].issue_thread for k, v in groups] + + objects = ItemManager() + all_objects = models.Manager() + + class Meta: + unique_together = (('uid_deprecated', 'event'),) + permissions = [ + ('match_item', 'Can match item') + ] + + def __str__(self): + 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) + created_at = models.DateTimeField(blank=True, null=True) + updated_at = models.DateTimeField(blank=True, null=True) + + @property + def items(self): + try: + history = self.item_history.order_by('-timestamp').all() + return [v for k, v in groupby(history, key=lambda item: item.item.id)] + except AttributeError: + return [] + + def __str__(self): + return '[' + str(self.id) + ']' + self.name + + +class ItemPlacement(models.Model): + id = models.AutoField(primary_key=True) + item = models.ForeignKey('Item', models.CASCADE, related_name='container_history') + container = models.ForeignKey('Container', models.CASCADE, related_name='item_history') + timestamp = models.DateTimeField(auto_now_add=True) + + +class Comment(models.Model): + id = models.AutoField(primary_key=True) + item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='comments') + comment = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return str(self.item) + ' comment #' + str(self.id) + + +class Event(models.Model): + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=255) + slug = models.CharField(max_length=255, unique=True) + start = models.DateTimeField(blank=True, null=True) + end = models.DateTimeField(blank=True, null=True) + pre_start = models.DateTimeField(blank=True, null=True) + post_end = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(null=True, auto_now_add=True) + updated_at = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return '[' + str(self.slug) + ']' + self.name + + class Meta: + db_table = 'common_event' diff --git a/core/inventory/serializers.py b/core/inventory/serializers.py new file mode 100644 index 0000000..0661476 --- /dev/null +++ b/core/inventory/serializers.py @@ -0,0 +1,164 @@ +from django.utils import timezone +from rest_framework import serializers +from rest_framework.relations import SlugRelatedField + +from files.models import File +from inventory.models import Event, Container, Item, Comment +from inventory.shared_serializers import BasicItemSerializer +from mail.models import EventAddress +from tickets.shared_serializers import BasicIssueSerializer + + +class EventSerializer(serializers.ModelSerializer): + addresses = SlugRelatedField(many=True, slug_field='address', queryset=EventAddress.objects.all()) + + class Meta: + model = Event + fields = ['id', 'slug', 'name', 'start', 'end', 'pre_start', 'post_end', 'addresses'] + read_only_fields = ['id'] + + def to_internal_value(self, data): + data = data.copy() + addresses = data.pop('addresses', None) + dict = super().to_internal_value(data) + if addresses: + dict['addresses'] = [EventAddress.objects.get_or_create(address=x)[0] for x in addresses] + return dict + + +class ContainerSerializer(serializers.ModelSerializer): + itemCount = serializers.SerializerMethodField() + + class Meta: + model = Container + fields = ['id', 'name', 'itemCount'] + read_only_fields = ['id', 'itemCount'] + + def get_itemCount(self, instance): + return len(instance.items) + + +class CommentSerializer(serializers.ModelSerializer): + + def validate(self, attrs): + if 'comment' not in attrs or attrs['comment'] == '': + raise serializers.ValidationError('comment cannot be empty') + return attrs + + class Meta: + model = Comment + fields = ('id', 'comment', 'timestamp', 'item') + + +class ItemSerializer(BasicItemSerializer): + timeline = serializers.SerializerMethodField() + dataImage = serializers.CharField(write_only=True, required=False) + related_issues = BasicIssueSerializer(many=True, read_only=True) + + class Meta: + model = Item + fields = ['cid', 'box', 'id', 'description', 'file', 'dataImage', 'returned', 'event', 'related_issues', + 'timeline'] + read_only_fields = ['id'] + prefetch_related_fields = ['comments', 'issue_relation_changes', 'container_history', + 'container_history__container', 'files', 'event', + 'issue_relation_changes__issue_thread', + 'issue_relation_changes__issue_thread__event', + 'issue_relation_changes__issue_thread__state_changes', + 'issue_relation_changes__issue_thread__assignments'] + + def to_internal_value(self, data): + container = None + returned = False + if 'cid' in data: + container = Container.objects.get(id=data['cid']) + if 'returned' in data: + returned = data['returned'] + internal = super().to_internal_value(data) + if container: + internal['container'] = container + if returned: + internal['returned_at'] = timezone.now() + return internal + + def validate(self, attrs): + if not 'container' in attrs and not self.partial: + raise serializers.ValidationError("This field cannot be empty.") + return super().validate(attrs) + + def create(self, validated_data): + if 'dataImage' in validated_data: + file = File.objects.create(data=validated_data['dataImage']) + validated_data.pop('dataImage') + item = Item.objects.create(**validated_data) + item.files.set([file]) + return item + return Item.objects.create(**validated_data) + + def update(self, instance, validated_data): + if 'returned' in validated_data: + if validated_data['returned']: + validated_data['returned_at'] = timezone.now() + validated_data.pop('returned') + if 'dataImage' in validated_data: + file = File.objects.create(data=validated_data['dataImage']) + validated_data.pop('dataImage') + instance.files.add(file) + return super().update(instance, validated_data) + + @staticmethod + def get_timeline(obj): + timeline = [] + for comment in obj.comments.all(): + timeline.append({ + 'type': 'comment', + 'id': comment.id, + 'timestamp': comment.timestamp, + 'comment': comment.comment, + }) + for relation in (obj.issue_relation_changes.all()): + timeline.append({ + 'type': 'issue_relation', + 'id': relation.id, + 'status': relation.status, + 'timestamp': relation.timestamp, + 'issue_thread': BasicIssueSerializer(relation.issue_thread).data, + }) + for placement in (obj.container_history.all()): + timeline.append({ + 'type': 'placement', + 'id': placement.id, + 'timestamp': placement.timestamp, + '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']} + + class Meta: + model = Item diff --git a/core/inventory/shared_serializers.py b/core/inventory/shared_serializers.py new file mode 100644 index 0000000..2cafd5f --- /dev/null +++ b/core/inventory/shared_serializers.py @@ -0,0 +1,31 @@ +from rest_framework import serializers + +from inventory.models import Event, Item + + +class BasicItemSerializer(serializers.ModelSerializer): + cid = serializers.SerializerMethodField() + box = serializers.SerializerMethodField() + file = serializers.SerializerMethodField() + returned = serializers.SerializerMethodField(required=False) + event = serializers.SlugRelatedField(slug_field='slug', queryset=Event.objects.all(), + allow_null=True, required=False) + + class Meta: + model = Item + fields = ['cid', 'box', 'id', 'description', 'file', 'returned', 'event'] + read_only_fields = ['id'] + + def get_cid(self, instance): + return instance.container.id if instance.container else None + + def get_box(self, instance): + return instance.container.name if instance.container else None + + def get_file(self, instance): + if len(instance.files.all()) > 0: + return sorted(instance.files.all(), key=lambda x: x.created_at, reverse=True)[0].hash + return None + + def get_returned(self, instance): + return instance.returned_at is not None diff --git a/core/inventory/tests/__init__.py b/core/inventory/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/inventory/tests/v2/__init__.py b/core/inventory/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/inventory/tests/v2/test_api.py b/core/inventory/tests/v2/test_api.py new file mode 100644 index 0000000..6904164 --- /dev/null +++ b/core/inventory/tests/v2/test_api.py @@ -0,0 +1,44 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken + +from authentication.models import ExtendedUser + + +class ApiTest(TestCase): + + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_root(self): + from core.settings import SYSTEM3_VERSION + response = self.client.get('/api/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["framework_version"], SYSTEM3_VERSION) + + def test_events(self): + response = self.client.get('/api/2/events/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_containers(self): + response = self.client.get('/api/2/boxes/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_files(self): + response = self.client.get('/api/2/files/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_items(self): + from inventory.models import Event + Event.objects.create(slug='TEST1', name='Event') + response = self.client.get('/api/2/TEST1/items/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) diff --git a/core/inventory/tests/v2/test_containers.py b/core/inventory/tests/v2/test_containers.py new file mode 100644 index 0000000..b74a4a7 --- /dev/null +++ b/core/inventory/tests/v2/test_containers.py @@ -0,0 +1,66 @@ +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken + +from authentication.models import ExtendedUser +from inventory.models import Container + + +class ContainerTestCase(TestCase): + + def setUp(self): + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_empty(self): + response = self.client.get('/api/2/boxes/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_members(self): + Container.objects.create(name='BOX') + response = self.client.get('/api/2/boxes/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], 1) + self.assertEqual(response.json()[0]['name'], 'BOX') + self.assertEqual(response.json()[0]['itemCount'], 0) + + def test_multi_members(self): + Container.objects.create(name='BOX 1') + Container.objects.create(name='BOX 2') + Container.objects.create(name='BOX 3') + response = self.client.get('/api/2/boxes/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 3) + + def test_create_container(self): + response = self.client.post('/api/2/box/', {'name': 'BOX'}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['id'], 1) + self.assertEqual(response.json()['name'], 'BOX') + self.assertEqual(response.json()['itemCount'], 0) + self.assertEqual(len(Container.objects.all()), 1) + self.assertEqual(Container.objects.all()[0].id, 1) + self.assertEqual(Container.objects.all()[0].name, 'BOX') + + def test_update_container(self): + box = Container.objects.create(name='BOX 1') + response = self.client.put(f'/api/2/box/{box.id}/', {'name': 'BOX 2'}, content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], 1) + self.assertEqual(response.json()['name'], 'BOX 2') + self.assertEqual(response.json()['itemCount'], 0) + self.assertEqual(len(Container.objects.all()), 1) + self.assertEqual(Container.objects.all()[0].id, 1) + self.assertEqual(Container.objects.all()[0].name, 'BOX 2') + + def test_delete_container(self): + box = Container.objects.create(name='BOX 1') + Container.objects.create(name='BOX 2') + self.assertEqual(len(Container.objects.all()), 2) + response = self.client.delete(f'/api/2/box/{box.id}/') + self.assertEqual(response.status_code, 204) + self.assertEqual(len(Container.objects.all()), 1) diff --git a/core/inventory/tests/v2/test_events.py b/core/inventory/tests/v2/test_events.py new file mode 100644 index 0000000..bd58515 --- /dev/null +++ b/core/inventory/tests/v2/test_events.py @@ -0,0 +1,94 @@ +from django.test import TestCase, Client +from inventory.models import Event + +client = Client() + + +class EventTestCase(TestCase): + + def test_empty(self): + response = client.get('/api/2/events/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_members(self): + Event.objects.create(slug='EVENT', name='Event') + response = client.get('/api/2/events/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['slug'], 'EVENT') + self.assertEqual(response.json()[0]['name'], 'Event') + + def test_multi_members(self): + Event.objects.create(slug='EVENT1', name='Event 1') + Event.objects.create(slug='EVENT2', name='Event 2') + Event.objects.create(slug='EVENT3', name='Event 3') + response = client.get('/api/2/events/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 3) + + def test_create_event(self): + response = client.post('/api/2/events/', {'slug': 'EVENT', 'name': 'Event'}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['slug'], 'EVENT') + self.assertEqual(response.json()['name'], 'Event') + self.assertEqual(len(Event.objects.all()), 1) + self.assertEqual(Event.objects.all()[0].slug, 'EVENT') + self.assertEqual(Event.objects.all()[0].name, 'Event') + + def test_update_event(self): + from rest_framework.test import APIClient + event = Event.objects.create(slug='EVENT1', name='Event 1') + response = APIClient().put(f'/api/2/events/{event.id}/', {'slug': 'EVENT2', 'name': 'Event 2 new'}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['slug'], 'EVENT2') + self.assertEqual(response.json()['name'], 'Event 2 new') + self.assertEqual(len(Event.objects.all()), 1) + self.assertEqual(Event.objects.all()[0].slug, 'EVENT2') + self.assertEqual(Event.objects.all()[0].name, 'Event 2 new') + + def test_update_event(self): + from rest_framework.test import APIClient + event = Event.objects.create(slug='EVENT1', name='Event 1') + response = APIClient().patch(f'/api/2/events/{event.id}/', {'addresses': ['foo@bar.baz', 'foo1@bar.baz']}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['slug'], 'EVENT1') + self.assertEqual(response.json()['name'], 'Event 1') + self.assertEqual(2, len(response.json()['addresses'])) + self.assertEqual('foo@bar.baz', response.json()['addresses'][0]) + self.assertEqual('foo1@bar.baz', response.json()['addresses'][1]) + self.assertEqual(len(Event.objects.all()), 1) + self.assertEqual(Event.objects.all()[0].slug, 'EVENT1') + self.assertEqual(Event.objects.all()[0].name, 'Event 1') + + def test_remove_event(self): + event = Event.objects.create(slug='EVENT1', name='Event 1') + Event.objects.create(slug='EVENT2', name='Event 2') + self.assertEqual(len(Event.objects.all()), 2) + response = client.delete(f'/api/2/events/{event.id}/') + self.assertEqual(response.status_code, 204) + self.assertEqual(len(Event.objects.all()), 1) + + def test_event_with_address(self): + from mail.models import EventAddress + event1 = Event.objects.create(slug='TEST1', name='Event') + EventAddress.objects.create(event=event1, address='foo@bar.baz') + response = self.client.get('/api/2/events/') + self.assertEqual(response.status_code, 200) + self.assertEqual(1, len(response.json())) + self.assertEqual('TEST1', response.json()[0]['slug']) + self.assertEqual('Event', response.json()[0]['name']) + self.assertEqual(1, len(response.json()[0]['addresses'])) + + def test_items_remove_addresss(self): + from mail.models import EventAddress + from rest_framework.test import APIClient + event1 = Event.objects.create(slug='TEST1', name='Event') + EventAddress.objects.create(event=event1, address='foo@bar.baz') + EventAddress.objects.create(event=event1, address='fo1o@bar.baz') + response = APIClient().patch(f'/api/2/events/{event1.id}/', {'addresses': ['foo1@bar.baz']}) + self.assertEqual(response.status_code, 200) + self.assertEqual('TEST1', response.json()['slug']) + self.assertEqual('Event', response.json()['name']) + self.assertEqual(1, len(response.json()['addresses'])) + self.assertEqual('foo1@bar.baz', response.json()['addresses'][0]) diff --git a/core/inventory/tests/v2/test_items.py b/core/inventory/tests/v2/test_items.py new file mode 100644 index 0000000..34c4739 --- /dev/null +++ b/core/inventory/tests/v2/test_items.py @@ -0,0 +1,345 @@ +from datetime import datetime, timedelta + +from django.utils import timezone +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken + +from authentication.models import ExtendedUser +from files.models import File +from inventory.models import Event, Container, Item, Comment, ItemPlacement + +from base64 import b64encode + +from tickets.models import IssueThread, ItemRelation + + +class ItemTestCase(TestCase): + + def setUp(self): + super().setUp() + self.event = Event.objects.create(slug='EVENT', name='Event') + self.box1 = Container.objects.create(name='BOX1') + self.box2 = Container.objects.create(name='BOX2') + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + + def test_empty(self): + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'[]') + + def test_members_and_timeline(self): + now = datetime.now() + item = Item.objects.create(container=self.box1, event=self.event, description='1') + comment = Comment.objects.create( + item=item, + comment="test", + timestamp=now + timedelta(seconds=3), + ) + match = ItemRelation.objects.create( + issue_thread=self.issue, + item=item, + timestamp=now + timedelta(seconds=4), + ) + placement = ItemPlacement.objects.create( + container=self.box2, + item=item, + timestamp=now + timedelta(seconds=5), + ) + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], item.id) + self.assertEqual(response.json()[0]['description'], '1') + self.assertEqual(response.json()[0]['box'], 'BOX2') + self.assertEqual(response.json()[0]['cid'], self.box2.id) + 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'], + 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") + self.assertEqual(response.json()[0]['related_issues'][0]['event'], "EVENT") + self.assertEqual(response.json()[0]['related_issues'][0]['state'], "pending_new") + + def test_members_with_file(self): + import base64 + item = Item.objects.create(container=self.box1, event=self.event, description='1') + file = File.objects.create(item=item, data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], item.id) + self.assertEqual(response.json()[0]['description'], '1') + self.assertEqual(response.json()[0]['box'], 'BOX1') + self.assertEqual(response.json()[0]['cid'], self.box1.id) + self.assertEqual(response.json()[0]['file'], file.hash) + self.assertEqual(response.json()[0]['returned'], False) + self.assertEqual(response.json()[0]['event'], self.event.slug) + self.assertEqual(len(response.json()[0]['related_issues']), 0) + + def test_members_with_two_file(self): + import base64 + item = Item.objects.create(container=self.box1, event=self.event, description='1') + file1 = File.objects.create(item=item, + data="data:text/plain;base64," + base64.b64encode(b"foo").decode('utf-8')) + file2 = File.objects.create(item=item, + data="data:text/plain;base64," + base64.b64encode(b"bar").decode('utf-8')) + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], item.id) + self.assertEqual(response.json()[0]['description'], '1') + self.assertEqual(response.json()[0]['box'], 'BOX1') + self.assertEqual(response.json()[0]['cid'], self.box1.id) + self.assertEqual(response.json()[0]['file'], file2.hash) + self.assertEqual(response.json()[0]['returned'], False) + self.assertEqual(response.json()[0]['event'], self.event.slug) + self.assertEqual(len(response.json()[0]['related_issues']), 0) + + def test_multi_members(self): + Item.objects.create(container=self.box1, event=self.event, description='1') + Item.objects.create(container=self.box1, event=self.event, description='2') + Item.objects.create(container=self.box1, event=self.event, description='3') + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 3) + + def test_create_item(self): + response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box1.id, 'description': '1'}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['id'], 1) + self.assertEqual(response.json()['description'], '1') + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['cid'], self.box1.id) + self.assertEqual(response.json()['file'], None) + self.assertEqual(response.json()['returned'], False) + self.assertEqual(response.json()['event'], self.event.slug) + self.assertEqual(len(response.json()['related_issues']), 0) + self.assertEqual(len(Item.objects.all()), 1) + self.assertEqual(Item.objects.all()[0].id, 1) + self.assertEqual(Item.objects.all()[0].description, '1') + self.assertEqual(Item.objects.all()[0].container.id, self.box1.id) + + def test_create_item_without_container(self): + response = self.client.post(f'/api/2/{self.event.slug}/item/', {'description': '1'}) + self.assertEqual(response.status_code, 400) + + def test_create_item_without_description(self): + response = self.client.post(f'/api/2/{self.event.slug}/item/', {'cid': self.box1.id}) + self.assertEqual(response.status_code, 400) + + def test_create_item_with_file(self): + import base64 + response = self.client.post(f'/api/2/{self.event.slug}/item/', + {'cid': self.box1.id, 'description': '1', + 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode( + 'utf-8')}, content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['id'], 1) + self.assertEqual(response.json()['description'], '1') + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['id'], self.box1.id) + self.assertEqual(len(response.json()['file']), 64) + self.assertEqual(len(Item.objects.all()), 1) + self.assertEqual(Item.objects.all()[0].id, 1) + self.assertEqual(Item.objects.all()[0].description, '1') + self.assertEqual(Item.objects.all()[0].container.id, self.box1.id) + self.assertEqual(len(File.objects.all()), 1) + + def test_update_item(self): + item = Item.objects.create(container=self.box1, event=self.event, description='1') + response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', {'description': '2'}, + content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], item.id) + self.assertEqual(response.json()['description'], '2') + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['cid'], self.box1.id) + self.assertEqual(response.json()['file'], None) + self.assertEqual(response.json()['returned'], False) + self.assertEqual(response.json()['event'], self.event.slug) + self.assertEqual(len(response.json()['related_issues']), 0) + self.assertEqual(len(Item.objects.all()), 1) + self.assertEqual(Item.objects.all()[0].id, 1) + self.assertEqual(Item.objects.all()[0].description, '2') + self.assertEqual(Item.objects.all()[0].container.id, self.box1.id) + + def test_update_item_with_file(self): + import base64 + item = Item.objects.create(container=self.box1, event=self.event, description='1') + response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', + {'description': '2', + 'dataImage': "data:text/plain;base64," + base64.b64encode(b"foo").decode( + 'utf-8')}, + content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], 1) + self.assertEqual(response.json()['description'], '2') + self.assertEqual(response.json()['box'], 'BOX1') + self.assertEqual(response.json()['id'], self.box1.id) + self.assertEqual(len(response.json()['file']), 64) + self.assertEqual(len(Item.objects.all()), 1) + self.assertEqual(Item.objects.all()[0].id, 1) + self.assertEqual(Item.objects.all()[0].description, '2') + self.assertEqual(Item.objects.all()[0].container.id, self.box1.id) + self.assertEqual(len(File.objects.all()), 1) + + def test_delete_item(self): + item = Item.objects.create(container=self.box1, event=self.event, description='1') + Item.objects.create(container=self.box1, event=self.event, description='2') + self.assertEqual(len(Item.objects.all()), 2) + response = self.client.delete(f'/api/2/{self.event.slug}/item/{item.id}/') + self.assertEqual(response.status_code, 204) + self.assertEqual(len(Item.objects.all()), 1) + + def test_delete_item2(self): + Item.objects.create(container=self.box1, event=self.event, description='1') + item2 = Item.objects.create(container=self.box1, event=self.event, description='2') + self.assertEqual(len(Item.objects.all()), 2) + response = self.client.delete(f'/api/2/{self.event.slug}/item/{item2.id}/') + self.assertEqual(response.status_code, 204) + self.assertEqual(len(Item.objects.all()), 1) + item3 = Item.objects.create(container=self.box1, event=self.event, description='3') + self.assertEqual(item3.id, 3) + self.assertEqual(len(Item.objects.all()), 2) + + def test_item_count(self): + Item.objects.create(container=self.box1, event=self.event, description='1') + Item.objects.create(container=self.box1, event=self.event, description='2') + response = self.client.get('/api/2/boxes/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + self.assertEqual(response.json()[0]['itemCount'], 2) + self.assertEqual(response.json()[1]['itemCount'], 0) + + def test_item_nonexistent(self): + response = self.client.get(f'/api/2/NOEVENT/item/') + self.assertEqual(response.status_code, 404) + + def test_item_return(self): + item = Item.objects.create(container=self.box1, event=self.event, description='1') + self.assertEqual(item.returned_at, None) + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + response = self.client.patch(f'/api/2/{self.event.slug}/item/{item.id}/', {'returned': True}, + content_type='application/json') + self.assertEqual(response.status_code, 200) + item.refresh_from_db() + self.assertNotEqual(item.returned_at, None) + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + def test_item_show_not_returned(self): + item1 = Item.objects.create(container=self.box1, event=self.event, description='1') + item2 = Item.objects.create(container=self.box1, event=self.event, description='2') + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + item2.returned_at = timezone.now() + item2.save() + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], item1.id) + + +class ItemSearchTestCase(TestCase): + + def setUp(self): + super().setUp() + self.event = Event.objects.create(slug='EVENT', name='Event') + self.box1 = Container.objects.create(name='BOX1') + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + self.item1 = Item.objects.create(container=self.box1, event=self.event, description='abc def') + self.item2 = Item.objects.create(container=self.box1, event=self.event, description='def ghi') + self.item3 = Item.objects.create(container=self.box1, event=self.event, description='jkl mno pqr') + self.item4 = Item.objects.create(container=self.box1, event=self.event, description='stu vwx') + + def test_search(self): + search_query = b64encode(b'abc').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.json())) + self.assertEqual(self.item1.id, response.json()[0]['id']) + self.assertEqual('abc def', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + + def test_search2(self): + search_query = b64encode(b'def').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(2, len(response.json())) + self.assertEqual(self.item1.id, response.json()[0]['id']) + self.assertEqual('abc def', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + self.assertEqual(self.item2.id, response.json()[1]['id']) + self.assertEqual('def ghi', response.json()[1]['description']) + self.assertEqual('BOX1', response.json()[1]['box']) + self.assertEqual(self.box1.id, response.json()[1]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + + def test_search3(self): + search_query = b64encode(b'jkl').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.json())) + self.assertEqual(self.item3.id, response.json()[0]['id']) + self.assertEqual('jkl mno pqr', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(1, response.json()[0]['search_score']) + + def test_search4(self): + search_query = b64encode(b'abc def').decode('utf-8') + response = self.client.get(f'/api/2/{self.event.slug}/items/{search_query}/') + self.assertEqual(200, response.status_code) + self.assertEqual(2, len(response.json())) + self.assertEqual(self.item1.id, response.json()[0]['id']) + self.assertEqual('abc def', response.json()[0]['description']) + self.assertEqual('BOX1', response.json()[0]['box']) + self.assertEqual(self.box1.id, response.json()[0]['cid']) + self.assertEqual(2, response.json()[0]['search_score']) + self.assertEqual(self.item2.id, response.json()[1]['id']) + self.assertEqual('def ghi', response.json()[1]['description']) + self.assertEqual('BOX1', response.json()[1]['box']) + self.assertEqual(self.box1.id, response.json()[1]['cid']) + self.assertEqual(1, response.json()[1]['search_score']) diff --git a/core/mail/__init__.py b/core/mail/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mail/admin.py b/core/mail/admin.py new file mode 100644 index 0000000..55619c2 --- /dev/null +++ b/core/mail/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from mail.models import Email, EventAddress + + +class EmailAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Email, EmailAdmin) + + +class EventAddressAdmin(admin.ModelAdmin): + pass + + +admin.site.register(EventAddress, EventAddressAdmin) diff --git a/core/mail/api_v2.py b/core/mail/api_v2.py new file mode 100644 index 0000000..0d96785 --- /dev/null +++ b/core/mail/api_v2.py @@ -0,0 +1,26 @@ +from rest_framework import routers, viewsets, serializers + +from mail.models import Email, EmailAttachment + + +class AttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = EmailAttachment + fields = ['hash', 'mime_type', 'name'] + + +class EmailSerializer(serializers.ModelSerializer): + class Meta: + model = Email + fields = '__all__' + + +class EmailViewSet(viewsets.ModelViewSet): + serializer_class = EmailSerializer + queryset = Email.objects.all() + + +router = routers.SimpleRouter() +router.register(r'mails', EmailViewSet, basename='mails') + +urlpatterns = router.urls diff --git a/core/mail/migrations/0001_initial.py b/core/mail/migrations/0001_initial.py new file mode 100644 index 0000000..71c54e7 --- /dev/null +++ b/core/mail/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.7 on 2023-12-09 02:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('inventory', '0001_initial'), + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='EventAddress', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('address', models.CharField(max_length=255)), + ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.event')), + ], + ), + migrations.CreateModel( + name='Email', + fields=[ + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('body', models.TextField()), + ('subject', models.CharField(max_length=255)), + ('sender', models.CharField(max_length=255)), + ('recipient', models.CharField(max_length=255)), + ('reference', models.CharField(max_length=255, null=True, unique=True)), + ('in_reply_to', models.CharField(max_length=255, null=True)), + ('raw', models.TextField()), + ('event', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.event')), + ('issue_thread', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='emails', to='tickets.issuethread')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/core/mail/migrations/0002_printed_quotable.py b/core/mail/migrations/0002_printed_quotable.py new file mode 100644 index 0000000..ce0d5da --- /dev/null +++ b/core/mail/migrations/0002_printed_quotable.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.7 on 2023-12-09 02:13 +import quopri + +from django.db import migrations + +from mail.protocol import unescape_and_decode_quoted_printable, unescape_and_decode_base64 + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('mail', '0001_initial'), + ] + + def convert_printed_quotable(apps, schema_editor): + Email = apps.get_model('mail', 'Email') + for mail in Email.objects.all(): + mail.body = unescape_and_decode_quoted_printable(mail.body) + mail.body = unescape_and_decode_base64(mail.body) + mail.subject = unescape_and_decode_quoted_printable(mail.subject) + mail.subject = unescape_and_decode_base64(mail.subject) + mail.save() + IssueThread = apps.get_model('tickets', 'IssueThread') + for issue in IssueThread.objects.all(): + issue.name = unescape_and_decode_quoted_printable(issue.name) + issue.name = unescape_and_decode_base64(issue.name) + issue.save() + + operations = [ + migrations.RunPython(convert_printed_quotable), + ] diff --git a/core/mail/migrations/0003_emailattachment.py b/core/mail/migrations/0003_emailattachment.py new file mode 100644 index 0000000..7796876 --- /dev/null +++ b/core/mail/migrations/0003_emailattachment.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.7 on 2024-01-09 20:56 + +from django.db import migrations, models +import django.db.models.deletion +import files.models +from mail.protocol import parse_email_body + + +class NullLogger: + def info(self, *args, **kwargs): + pass + + def warning(self, *args, **kwargs): + pass + + def debug(self, *args, **kwargs): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ('mail', '0002_printed_quotable'), + ] + + def generate_email_attachments(apps, schema_editor): + Email = apps.get_model('mail', 'Email') + for email in Email.objects.all(): + raw = email.raw + if raw is None or raw == '': + continue + parsed, body, attachments = parse_email_body(raw.encode('utf-8'), NullLogger()) + email.attachments.clear() + for attachment in attachments: + email.attachments.add(attachment) + email.body = body + email.save() + + operations = [ + migrations.CreateModel( + name='EmailAttachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(blank=True, null=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('file', models.ImageField(upload_to=files.models.hash_upload)), + ('mime_type', models.CharField(max_length=255)), + ('hash', models.CharField(max_length=64, unique=True)), + ('name', models.CharField(max_length=255)), + ('email', + models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', + to='mail.email')), + ], + options={ + 'abstract': False, + }, + ), + migrations.RunPython(generate_email_attachments), + ] diff --git a/core/mail/migrations/0004_alter_emailattachment_file.py b/core/mail/migrations/0004_alter_emailattachment_file.py new file mode 100644 index 0000000..4342c8e --- /dev/null +++ b/core/mail/migrations/0004_alter_emailattachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.7 on 2024-01-10 19:04 + +from django.db import migrations, models +import files.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mail', '0003_emailattachment'), + ] + + operations = [ + migrations.AlterField( + model_name='emailattachment', + name='file', + field=models.FileField(upload_to=files.models.hash_upload), + ), + ] diff --git a/core/mail/migrations/0005_alter_eventaddress_event.py b/core/mail/migrations/0005_alter_eventaddress_event.py new file mode 100644 index 0000000..30b79bf --- /dev/null +++ b/core/mail/migrations/0005_alter_eventaddress_event.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.7 on 2024-11-03 18:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_alter_event_created_at_alter_item_created_at'), + ('mail', '0004_alter_emailattachment_file'), + ] + + operations = [ + migrations.AlterField( + model_name='eventaddress', + name='event', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='addresses', to='inventory.event'), + ), + ] diff --git a/core/mail/migrations/0006_email_raw_file.py b/core/mail/migrations/0006_email_raw_file.py new file mode 100644 index 0000000..4086af8 --- /dev/null +++ b/core/mail/migrations/0006_email_raw_file.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.7 on 2024-11-08 20:37 +from django.core.files.base import ContentFile +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('mail', '0005_alter_eventaddress_event'), + ] + + def move_raw_mails_to_file(apps, schema_editor): + Email = apps.get_model('mail', 'Email') + for email in Email.objects.all(): + raw_content = email.raw + path = "mail_{}".format(email.id) + if len(raw_content): + email.raw_file.save(path, ContentFile(raw_content)) + email.save() + + operations = [ + migrations.AddField( + model_name='email', + name='raw_file', + field=models.FileField(null=True, upload_to='raw_mail/'), + ), + migrations.RunPython(move_raw_mails_to_file), + migrations.RemoveField( + model_name='email', + name='raw', + ), + migrations.AlterField( + model_name='email', + name='raw_file', + field=models.FileField(upload_to='raw_mail/'), + ), + ] diff --git a/core/mail/migrations/__init__.py b/core/mail/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mail/models.py b/core/mail/models.py new file mode 100644 index 0000000..36cd2b3 --- /dev/null +++ b/core/mail/models.py @@ -0,0 +1,52 @@ +import random + +from django.db import models +from django_softdelete.models import SoftDeleteModel + +from core.settings import MAIL_DOMAIN, ACTIVE_SPAM_TRAINING +from files.models import AbstractFile +from inventory.models import Event +from tickets.models import IssueThread + + +class Email(SoftDeleteModel): + id = models.AutoField(primary_key=True) + timestamp = models.DateTimeField(auto_now_add=True) + body = models.TextField() + subject = models.CharField(max_length=255) + sender = models.CharField(max_length=255) + recipient = models.CharField(max_length=255) + reference = models.CharField(max_length=255, null=True, unique=True) + in_reply_to = models.CharField(max_length=255, null=True) + raw_file = models.FileField(upload_to='raw_mail/') + issue_thread = models.ForeignKey(IssueThread, models.SET_NULL, null=True, related_name='emails') + event = models.ForeignKey(Event, models.SET_NULL, null=True) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if not self.reference: + self.reference = f'<{random.randint(0, 1000000000):09}@{MAIL_DOMAIN}>' + self.save() + + def train_spam(self): + if ACTIVE_SPAM_TRAINING and self.raw_file.path: + import subprocess + path = self.raw_file.path + subprocess.run(["rspamc", "learn_spam", path]) + + def train_ham(self): + if ACTIVE_SPAM_TRAINING and self.raw_file.path: + import subprocess + path = self.raw_file.path + subprocess.run(["rspamc", "learn_ham", path]) + + +class EventAddress(models.Model): + id = models.AutoField(primary_key=True) + event = models.ForeignKey(Event, models.SET_NULL, null=True, related_name='addresses') + address = models.CharField(max_length=255) + + +class EmailAttachment(AbstractFile): + email = models.ForeignKey(Email, models.CASCADE, related_name='attachments', null=True) + name = models.CharField(max_length=255) diff --git a/core/mail/protocol.py b/core/mail/protocol.py new file mode 100644 index 0000000..23ae696 --- /dev/null +++ b/core/mail/protocol.py @@ -0,0 +1,330 @@ +import logging +from re import match + +import aiosmtplib +from channels.layers import get_channel_layer +from channels.db import database_sync_to_async +from django.core.files.base import ContentFile + +from mail.models import Email, EventAddress, EmailAttachment +from notify_sessions.models import SystemEvent +from tickets.models import IssueThread + + +class SpecialMailException(Exception): + pass + + +def find_quoted_printable(s, marker): + positions = [i for i in range(len(s)) if s.lower().startswith('=?utf-8?' + marker + '?', i)] + for pos in positions: + end = s.find('?=', pos + 10) + if end == -1: + continue + yield pos, end + 3 + + +def unescape_and_decode_quoted_printable(s): + import quopri + decoded = '' + last_end = 0 + for start, end in find_quoted_printable(s, 'q'): + decoded += s[last_end:start] + decoded += quopri.decodestring(s[start + 10:end - 3]).decode('utf-8') + last_end = end + decoded += s[last_end:] + return decoded + + +def unescape_and_decode_base64(s): + import base64 + decoded = '' + last_end = 0 + for start, end in find_quoted_printable(s, 'b'): + decoded += s[last_end:start] + decoded += base64.b64decode(s[start + 10:end - 3]).decode('utf-8') + last_end = end + decoded += s[last_end:] + return decoded + + +def unescape_simplified_quoted_printable(s, encoding='utf-8'): + 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]) + + +def collect_references(issue_thread): + mails = issue_thread.emails.order_by('timestamp') + references = [] + for mail in mails: + if mail.reference: + references.append(mail.reference) + return references + + +def make_reply(reply_email, references=None, event=None): + from email.message import EmailMessage + from core.settings import MAIL_DOMAIN + event = event or "mail" + reply = EmailMessage() + reply["From"] = reply_email.sender + reply["To"] = reply_email.recipient + reply["Subject"] = reply_email.subject + reply["Reply-To"] = f"{event}@{MAIL_DOMAIN}" + if reply_email.in_reply_to: + reply["In-Reply-To"] = reply_email.in_reply_to + if reply_email.reference: + reply["Message-ID"] = reply_email.reference + else: + reply["Message-ID"] = reply_email.id + "@" + MAIL_DOMAIN + reply_email.reference = reply["Message-ID"] + 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) + + +def find_active_issue_thread(in_reply_to, address, subject, event, spam=False): + from re import match + uuid_match = match(r'^ticket\+([a-f0-9-]{36})@', address) + if uuid_match: + issue = IssueThread.objects.filter(uuid=uuid_match.group(1)) + if issue.exists(): + return issue.first(), False + reply_to = Email.objects.filter(reference=in_reply_to) + 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') + return issue, True + + +def find_target_event(address): + try: + address_map = EventAddress.objects.get(address=address) + if address_map.event: + return address_map.event + except EventAddress.DoesNotExist: + pass + 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 + + attachments = [] + + parsed = email.message_from_bytes(raw) + body = "" + 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: + segment = part.get_payload() + if not segment: + continue + segment = decode_email_segment(segment.encode('utf-8'), charset, part.get('Content-Transfer-Encoding')) + 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) + chash = sha256(file.read()).hexdigest() + name = part.get_filename() + if name is None: + name = "unnamed" + attachment, _ = EmailAttachment.objects.get_or_create( + name=name, mime_type=ctype, file=file, hash=chash) + attachment.save() + attachments.append(attachment) + if 'inline' in cdispo: + body = body + f'' + log.info("Image %s %s", ctype, attachment.id) + else: + log.info("Attachment %s %s", ctype, cdispo) + else: + if parsed.get_content_type() == 'text/plain': + body = parsed.get_payload() + elif parsed.get_content_type() == 'text/html': + from bs4 import BeautifulSoup + import re + body = parsed.get_payload() + soup = BeautifulSoup(body, 'html.parser') + body = re.sub(r'([\r\n]+.?)*[\r\n]', r'\n', soup.get_text()).strip('\n') + 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')) + log.debug(body) + + return parsed, body, attachments + + +@database_sync_to_async +def receive_email(envelope, log=None): + parsed, body, attachments = parse_email_body(envelope.content, log) + + 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') + + if match(r'^([a-zA-Z ]*<)?MAILER-DAEMON@', header_from) and envelope.mail_from.strip("<>") == "": + log.warning("Ignoring mailer daemon") + raise SpecialMailException("Ignoring mailer daemon") + + 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") + + 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')) + if not subject: + subject = "No subject" + subject = decode_inline_encodings(subject) + recipient = decode_inline_encodings(recipient) + sender = decode_inline_encodings(sender) + target_event = find_target_event(recipient) + + active_issue_thread, new = find_active_issue_thread( + header_in_reply_to, recipient, subject, target_event, suspected_spam) + + 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, + issue_thread=active_issue_thread) + for attachment in attachments: + email.attachments.add(attachment) + email.save() + + reply = 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: + subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]" + body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels. + +We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \ +workload is high. We will not forget about your request and get back in touch once we have updated information on your \ +request. Requests for devices, wallets, credit cards or similar items will be handled with priority. + +If you happen to find your lost item or just want to add additional information, please reply to this email. Please \ +do not create a new request. + +Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid()) + reply_email = Email.objects.create( + sender=recipient, recipient=sender, body=body, subject=subject, + in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread) + reply = make_reply(reply_email, references, event=target_event.slug if target_event else None) + else: + # change state if not new + if active_issue_thread.state != 'pending_new': + active_issue_thread.state = 'pending_open' + active_issue_thread.save() + + return email, new, reply, active_issue_thread + + +class LMTPHandler: + async def handle_RCPT(self, server, session, envelope, address, rcpt_options): + from core.settings import MAIL_DOMAIN + address = address.lower() + if not address.endswith('@' + MAIL_DOMAIN): + return '550 not relaying to that domain' + envelope.rcpt_tos.append(address) + return '250 OK' + + async def handle_DATA(self, server, session, envelope): + log = logging.getLogger('mail.log') + log.setLevel(logging.DEBUG) + log.info('Message from %s' % envelope.mail_from) + log.info('Message for %s' % envelope.rcpt_tos) + log.info('Message data:\n') + + content = None + try: + content = envelope.content + email, new, reply, thread = await receive_email(envelope, log) + log.info(f"Created email {email.id}") + 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"}) + log.info(f"Sent message to frontend") + if new and reply: + log.info('Sending message to %s' % reply['To']) + await send_smtp(reply) + log.info("Sent auto reply") + + return '250 Message accepted for delivery' + except SpecialMailException as e: + import uuid + random_filename = 'special-' + str(uuid.uuid4()) + with open(random_filename, 'wb') as f: + f.write(content) + log.warning(f"Special mail exception: {e} saved to {random_filename}") + return '250 Message accepted for delivery' + except Exception as e: + from hashlib import sha256 + random_filename = 'mail-' + sha256(content).hexdigest() + with open(random_filename, 'wb') as f: + f.write(content) + log.error(f"Saved email to {random_filename} because of error %s (%s)", e, type(e)) + return '451 Internal server error' diff --git a/core/mail/socket.py b/core/mail/socket.py new file mode 100644 index 0000000..312b916 --- /dev/null +++ b/core/mail/socket.py @@ -0,0 +1,31 @@ +from abc import ABCMeta + +from aiosmtpd.controller import BaseController, UnixSocketMixin +from aiosmtpd.lmtp import LMTP + + +class BaseAsyncController(BaseController, metaclass=ABCMeta): + def __init__( + self, + handler, + loop, + **SMTP_parameters, + ): + super().__init__( + handler, + loop, + **SMTP_parameters, + ) + + def serve(self): + return self._create_server() + + +class UnixSocketLMTPController(UnixSocketMixin, BaseAsyncController): + def factory(self): + return LMTP(self.handler) + + def _trigger_server(self): # pragma: no-unixsock + # Prevent confusion on which _trigger_server() to invoke. + # Or so LGTM.com claimed + UnixSocketMixin._trigger_server(self) diff --git a/core/mail/tests/__init__.py b/core/mail/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mail/tests/v2/__init__.py b/core/mail/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/mail/tests/v2/test_mails.py b/core/mail/tests/v2/test_mails.py new file mode 100644 index 0000000..95d35cb --- /dev/null +++ b/core/mail/tests/v2/test_mails.py @@ -0,0 +1,1204 @@ +import inspect +from unittest import mock + +from django.test import TestCase, Client +from django.contrib.auth.models import Permission +from knox.models import AuthToken + +from authentication.models import ExtendedUser +from core.settings import MAIL_DOMAIN +from inventory.models import Event +from mail.models import Email, EventAddress, EmailAttachment +from mail.protocol import LMTPHandler +from tickets.models import IssueThread, StateChange + +expected_auto_reply_subject = 'Re: {} [#{}]' + +expected_auto_reply = '''Your request (#{}) has been received and will be reviewed by our lost&found angels. + +We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \ +workload is high. We will not forget about your request and get back in touch once we have updated information on your \ +request. Requests for devices, wallets, credit cards or similar items will be handled with priority. + +If you happen to find your lost item or just want to add additional information, please reply to this email. Please \ +do not create a new request. + +Your c3lf (Cloakroom + Lost&Found) Team''' + + +def make_mocked_coro(return_value=mock.sentinel, raise_exception=mock.sentinel): + async def mock_coro(*args, **kwargs): + if raise_exception is not mock.sentinel: + raise raise_exception + if not inspect.isawaitable(return_value): + return return_value + await return_value + + return mock.Mock(wraps=mock_coro) + + +class EmailsApiTest(TestCase): + + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_mails(self): + Event.objects.get_or_create( + name="Test event", + slug="test-event", + ) + Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + ) + response = self.client.get('/api/2/mails/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['subject'], 'test') + self.assertEqual(response.json()[0]['body'], 'test') + self.assertEqual(response.json()[0]['sender'], 'test') + self.assertEqual(response.json()[0]['recipient'], 'test') + + def test_mails_empty(self): + response = self.client.get('/api/2/mails/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + +class LMTPHandlerTestCase(TestCase): # TODO replace with less hacky test + + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_handle_client(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@localhost'] + envelope.content = b'Subject: test\nFrom: test3@test\nTo: test4@localhost\nMessage-ID: <1@test>\n\ntest' + 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@localhost', 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(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()), + Email.objects.all()[1].subject) + self.assertEqual('test2@localhost', 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_handle_quoted_printable(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 =?utf-8?Q?=C3=A4?=\nFrom: test3@test\nTo: test4@test\nMessage-ID: <1@test>\n\nText mit Quoted-Printable-Kodierung: =?utf-8?Q?=C3=A4=C3=B6=C3=BC=C3=9F?=' + 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('Text mit Quoted-Printable-Kodierung: äöüß', Email.objects.all()[0].body) + self.assertTrue(Email.objects.all()[0].raw_file.path) + + def test_handle_quoted_printable_2(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: =?UTF-8?Q?suche_M=C3=BCtze?=\nFrom: test3@test\nTo: test4@test\nMessage-ID: <1@test>\n\nText mit Quoted-Printable-Kodierung: =?utf-8?Q?=C3=A4=C3=B6=C3=BC=C3=9F?=' + 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('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) + + def test_handle_base64_inline(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: =?utf-8?B?dGVzdA==?=\nFrom: test3@test\nTo: test4@test\nMessage-ID: <1@test>\n\nText mit Base64-Kodierung: =?utf-8?B?w6TDtsO8w58=?=' + 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('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) + + def test_handle_client_reply(self): + issue_thread = IssueThread.objects.create( + name="test", + ) + mail1 = Email.objects.create( + subject='test subject', + body='test', + sender='test1@test', + recipient='test2@test', + issue_thread=issue_thread, + ) + mail1_reply = Email.objects.create( + subject='Message received', + body='Thank you for your message.', + sender='test2@test', + recipient='test1@test', + in_reply_to=mail1.reference, + issue_thread=issue_thread, + ) + 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 = (f'Subject: Re: test\nFrom: test3@test\nTo: test4@test\nMessage-ID: <3@test>\n' + f'In-Reply-To: {mail1_reply.reference}'.encode('utf-8') + b'\n\ntest') + result = async_to_sync(handler.handle_DATA)(server, session, envelope) + self.assertEqual(result, '250 Message accepted for delivery') + self.assertEqual(len(Email.objects.all()), 3) + self.assertEqual(len(IssueThread.objects.all()), 1) + aiosmtplib.send.assert_not_called() + self.assertEqual(Email.objects.all()[2].subject, 'Re: test') + self.assertEqual(Email.objects.all()[2].sender, 'test1@test') + self.assertEqual(Email.objects.all()[2].recipient, 'test2@test') + self.assertEqual(Email.objects.all()[2].body, 'test') + self.assertEqual(Email.objects.all()[2].issue_thread, issue_thread) + self.assertEqual(Email.objects.all()[2].reference, '<3@test>') + self.assertEqual(Email.objects.all()[2].in_reply_to, mail1_reply.reference) + 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) + + def test_handle_client_reply_2(self): + issue_thread = IssueThread.objects.create( + name="test", + ) + mail1 = Email.objects.create( + subject='test subject', + body='test', + sender='test1@test', + recipient='test2@test', + issue_thread=issue_thread, + ) + mail1_reply = Email.objects.create( + subject='Message received', + body='Thank you for your message.', + sender='test2@test', + recipient='test1@test', + in_reply_to=mail1.reference, + issue_thread=issue_thread, + ) + StateChange.objects.create( + issue_thread=issue_thread, + state='waiting_details', + ) + self.assertEqual(IssueThread.objects.all()[0].state, 'waiting_details') + 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 = (f'Subject: Re: test\nFrom: test3@test\nTo: test4@test\nMessage-ID: <3@test>\n' + f'In-Reply-To: {mail1_reply.reference}'.encode('utf-8') + b'\n\ntest') + result = async_to_sync(handler.handle_DATA)(server, session, envelope) + self.assertEqual(result, '250 Message accepted for delivery') + self.assertEqual(len(Email.objects.all()), 3) + self.assertEqual(len(IssueThread.objects.all()), 1) + aiosmtplib.send.assert_not_called() + self.assertEqual(Email.objects.all()[2].subject, 'Re: test') + self.assertEqual(Email.objects.all()[2].sender, 'test1@test') + self.assertEqual(Email.objects.all()[2].recipient, 'test2@test') + self.assertEqual(Email.objects.all()[2].body, 'test') + self.assertEqual(Email.objects.all()[2].issue_thread, issue_thread) + self.assertEqual(Email.objects.all()[2].reference, '<3@test>') + self.assertEqual(Email.objects.all()[2].in_reply_to, mail1_reply.reference) + 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) + + def test_mail_reply(self): + issue_thread = IssueThread.objects.create( + name="test subject", + ) + mail1 = Email.objects.create( + subject='test subject', + body='test', + sender='test1@test', + recipient='test2@localhost', + issue_thread=issue_thread, + ) + mail1_reply = Email.objects.create( + subject='Message received', + body='Thank you for your message.', + sender='test2@localhost', + recipient='test1@test', + in_reply_to=mail1.reference, + issue_thread=issue_thread, + ) + import aiosmtplib + aiosmtplib.send = make_mocked_coro() + response = self.client.post(f'/api/2/tickets/{issue_thread.id}/reply/', { + 'message': 'test' + }) + self.assertEqual(response.status_code, 201) + self.assertEqual(len(Email.objects.all()), 3) + self.assertEqual(len(IssueThread.objects.all()), 1) + aiosmtplib.send.assert_called_once() + self.assertEqual(Email.objects.all()[0].subject, 'test subject') + self.assertEqual(Email.objects.all()[0].sender, 'test1@test') + self.assertEqual(Email.objects.all()[0].recipient, 'test2@localhost') + self.assertEqual(Email.objects.all()[0].body, 'test') + self.assertEqual(Email.objects.all()[0].issue_thread, issue_thread) + self.assertEqual(Email.objects.all()[0].reference, mail1.reference) + self.assertEqual(Email.objects.all()[1].subject, 'Message received') + self.assertEqual(Email.objects.all()[1].sender, 'test2@localhost') + self.assertEqual(Email.objects.all()[1].recipient, 'test1@test') + self.assertEqual(Email.objects.all()[1].body, 'Thank you for your message.') + self.assertEqual(Email.objects.all()[1].issue_thread, issue_thread) + self.assertTrue(Email.objects.all()[1].reference.startswith("<")) + self.assertTrue(Email.objects.all()[1].reference.endswith("@localhost>")) + self.assertEqual(Email.objects.all()[1].in_reply_to, mail1.reference) + self.assertEqual(Email.objects.all()[2].subject, 'Re: test subject [#{0}]'.format(issue_thread.short_uuid())) + self.assertEqual(Email.objects.all()[2].sender, 'test2@localhost') + self.assertEqual(Email.objects.all()[2].recipient, 'test1@test') + self.assertEqual(Email.objects.all()[2].body, 'test') + self.assertEqual(Email.objects.all()[2].issue_thread, issue_thread) + self.assertTrue(Email.objects.all()[2].reference.startswith("<")) + self.assertTrue(Email.objects.all()[2].reference.endswith("@localhost>")) + self.assertEqual(Email.objects.all()[2].in_reply_to, mail1.reference) + + def test_match_event(self): + event = Event.objects.create( + name="Test event", + slug="test-event", + ) + event_address = EventAddress.objects.create( + event=event, + address="test_event@localhost", + ) + 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 = ['test_event@localhost'] + envelope.content = b'Subject: test\nFrom: test1@test\nTo: test_event@localhost\nMessage-ID: <1@test>\n\ntest' + 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(event, Email.objects.all()[0].event) + self.assertEqual(event, Email.objects.all()[1].event) + self.assertEqual('test', Email.objects.all()[0].subject) + self.assertEqual(expected_auto_reply_subject.format('test', IssueThread.objects.all()[0].short_uuid()), + Email.objects.all()[1].subject) + self.assertEqual('test1@test', Email.objects.all()[0].sender) + self.assertEqual('test_event@localhost', Email.objects.all()[0].recipient) + self.assertEqual('test_event@localhost', Email.objects.all()[1].sender) + self.assertEqual('test1@test', Email.objects.all()[1].recipient) + self.assertEqual('test', Email.objects.all()[0].body) + 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()[0].issue_thread) + self.assertEqual(IssueThread.objects.all()[0], Email.objects.all()[1].issue_thread) + self.assertEqual('<1@test>', Email.objects.all()[0].reference) + self.assertEqual(None, Email.objects.all()[0].in_reply_to) + 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(None, IssueThread.objects.all()[0].assigned_to) + self.assertEqual(1, len(IssueThread.objects.all())) + self.assertEqual('pending_new', IssueThread.objects.all()[0].state) + states = StateChange.objects.filter(issue_thread=IssueThread.objects.all()[0]) + self.assertEqual(1, len(states)) + self.assertEqual('pending_new', states[0].state) + self.assertEqual(event, IssueThread.objects.all()[0].event) + + def test_mail_html_body(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=utf-8 + +
+
+

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()), 2) + self.assertEqual(len(IssueThread.objects.all()), 1) + self.assertEqual(len(EmailAttachment.objects.all()), 0) + 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('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(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_split_text_inline_image(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 + +test1 + +--abc +Content-Type: image/jpeg; name="test.jpg" +Content-Disposition: inline; filename="test.jpg" +Content-Transfer-Encoding: base64 +Content-ID: <1> +X-Attachment-Id: 1 + +dGVzdGltYWdl + +--abc +Content-Type: text/plain; charset=utf-8 + +test2 + +--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\ntest2\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_with_attachment(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" + +--abc +Content-Type: text/plain; charset=utf-8 + +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_mail_noreply(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 = 'noreply@test' + envelope.rcpt_tos = ['test2@test'] + envelope.content = b'Subject: test\nFrom: noreply@test\nTo: test2@test\nMessage-ID: <1@test>\n\ntest' + 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) + self.assertEqual(len(IssueThread.objects.all()), 1) + aiosmtplib.send.assert_not_called() + self.assertEqual('test', Email.objects.all()[0].subject) + self.assertEqual('noreply@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_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_empty_subject(self): + from aiosmtpd.smtp import Envelope + from asgiref.sync import async_to_sync + import aiosmtplib + import logging + logging.disable(logging.CRITICAL) + 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'From: noreply@test\nTo: test2@test\nMessage-ID: <1@test>\n\ntest' + result = async_to_sync(handler.handle_DATA)(server, session, envelope) + logging.disable(logging.NOTSET) + 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('No subject', 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(expected_auto_reply_subject.format('No subject', 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('No subject', 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_empty_body(self): + from aiosmtpd.smtp import Envelope + from asgiref.sync import async_to_sync + import aiosmtplib + import logging + logging.disable(logging.CRITICAL) + aiosmtplib.send = make_mocked_coro() + handler = LMTPHandler() + server = mock.Mock() + session = mock.Mock() + envelope = Envelope() + envelope.mail_from = '' + envelope.rcpt_tos = ['test2@test'] + envelope.content = b'Subject: test\nFrom: \nTo: test2@test\nMessage-ID: <1@test>\n\n' + result = async_to_sync(handler.handle_DATA)(server, session, envelope) + logging.disable(logging.NOTSET) + 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('', Email.objects.all()[0].sender) + self.assertEqual('test2@test', Email.objects.all()[0].recipient) + self.assertEqual('', 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('', 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_plus_issue_thread(self): + issue_thread = IssueThread.objects.create( + name="test subject", + ) + mail1 = Email.objects.create( + subject='test subject', + body='test', + sender='test1@test', + recipient='test2@localhost', + issue_thread=issue_thread, + ) + mail2 = Email.objects.create( + subject='Message received', + body='Thank you for your message.', + sender='test2@localhost', + recipient='test1@test', + in_reply_to=mail1.reference, + issue_thread=issue_thread, + ) + Email.objects.create( + subject='Re: Message received', + body='any updates?', + sender='test3@test', + recipient='test2@localhost', + in_reply_to=mail2.reference, + issue_thread=issue_thread, + ) + from aiosmtpd.smtp import Envelope + from asgiref.sync import async_to_sync + import aiosmtplib + import logging + logging.disable(logging.CRITICAL) + aiosmtplib.send = make_mocked_coro() + handler = LMTPHandler() + server = mock.Mock() + session = mock.Mock() + envelope = Envelope() + envelope.mail_from = '' + envelope.rcpt_tos = ['ticket+{}@test'.format(issue_thread.uuid)] + envelope.content = (f'Subject: foo\nFrom: \nTo: ticket+{issue_thread.uuid}@test\n' + f'Message-ID: <3@test>\n\nbar'.encode('utf-8')) + result = async_to_sync(handler.handle_DATA)(server, session, envelope) + logging.disable(logging.NOTSET) + self.assertEqual('250 Message accepted for delivery', result) + self.assertEqual(4, len(Email.objects.all())) + self.assertEqual(4, len(Email.objects.filter(issue_thread=issue_thread))) + self.assertEqual(1, len(IssueThread.objects.all())) + aiosmtplib.send.assert_not_called() + self.assertEqual(Email.objects.all()[3].subject, 'foo') + self.assertEqual(Email.objects.all()[3].sender, '') + self.assertEqual(Email.objects.all()[3].recipient, 'ticket+{}@test'.format(issue_thread.uuid)) + self.assertEqual(Email.objects.all()[3].body, 'bar') + self.assertEqual(Email.objects.all()[3].issue_thread, issue_thread) + self.assertEqual(Email.objects.all()[3].reference, '<3@test>') + self.assertEqual('test subject', IssueThread.objects.all()[0].name) + response = self.client.post(f'/api/2/tickets/{issue_thread.id}/reply/', { + 'message': 'test' + }) + self.assertEqual(response.status_code, 201) + self.assertEqual(5, len(Email.objects.all())) + self.assertEqual(5, len(Email.objects.filter(issue_thread=issue_thread))) + self.assertEqual(1, len(IssueThread.objects.all())) + self.assertEqual(Email.objects.all()[4].subject, 'Re: test subject [#{0}]'.format(issue_thread.short_uuid())) + self.assertEqual(Email.objects.all()[4].sender, 'test2@localhost') + self.assertEqual(Email.objects.all()[4].recipient, 'test1@test') + self.assertEqual(Email.objects.all()[4].body, 'test') + self.assertEqual(Email.objects.all()[4].issue_thread, issue_thread) + self.assertTrue(Email.objects.all()[4].reference.startswith("<")) + self.assertTrue(Email.objects.all()[4].reference.endswith("@localhost>")) + self.assertEqual(Email.objects.all()[4].in_reply_to, mail1.reference) + self.assertEqual('test subject', IssueThread.objects.all()[0].name) + self.assertEqual('pending_new', IssueThread.objects.all()[0].state) + 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 + 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=utf-8 + +thank you =?utf-8?Q?=F0=9F=98=8A?=''' # thank you 😊 + + 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('thank you 😊', 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_non_utf8(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=iso-8859-1 + +hello \xe4\xf6\xfc''' + + 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('hello äöü', 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_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 + 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=utf-8 +Content-Transfer-Encoding: quoted-printable + +hello =C3=A4=C3=B6=C3=BC''' + + 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('hello äöü', 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_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) diff --git a/core/manage.py b/core/manage.py new file mode 100755 index 0000000..f2a662c --- /dev/null +++ b/core/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/core/notify_sessions/__init__.py b/core/notify_sessions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notify_sessions/admin.py b/core/notify_sessions/admin.py new file mode 100644 index 0000000..5f518e7 --- /dev/null +++ b/core/notify_sessions/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from notify_sessions.models import SystemEvent + + +class SystemEventAdmin(admin.ModelAdmin): + pass + + +admin.site.register(SystemEvent, SystemEventAdmin) diff --git a/core/notify_sessions/api_v2.py b/core/notify_sessions/api_v2.py new file mode 100644 index 0000000..30ff737 --- /dev/null +++ b/core/notify_sessions/api_v2.py @@ -0,0 +1,21 @@ +from rest_framework import routers, viewsets, serializers + +from tickets.models import IssueThread +from notify_sessions.models import SystemEvent + + +class SystemEventSerializer(serializers.ModelSerializer): + class Meta: + model = SystemEvent + fields = '__all__' + + +class SystemEventViewSet(viewsets.ModelViewSet): + serializer_class = SystemEventSerializer + queryset = SystemEvent.objects.all() + + +router = routers.SimpleRouter() +router.register(r'systemevents', SystemEventViewSet, basename='systemevents') + +urlpatterns = router.urls diff --git a/core/notify_sessions/consumers.py b/core/notify_sessions/consumers.py new file mode 100644 index 0000000..06413b5 --- /dev/null +++ b/core/notify_sessions/consumers.py @@ -0,0 +1,57 @@ +import logging +from json.decoder import JSONDecodeError +from json import loads as json_loads +from json import dumps as json_dumps + +from channels.generic.websocket import AsyncWebsocketConsumer + + +class NotifyConsumer(AsyncWebsocketConsumer): + + def __init__(self, *args, **kwargs): + super().__init__(args, kwargs) + self.log = logging.getLogger("server.log") + self.room_group_name = "general" + + async def connect(self): + # Join room group + await self.channel_layer.group_add(self.room_group_name, self.channel_name) + self.log.info(f"Added {self.channel_name} channel to {self.room_group_name} group") + await self.accept() + + async def disconnect(self, close_code): + # Leave room group + await self.channel_layer.group_discard(self.room_group_name, self.channel_name) + + # Receive message from WebSocket + async def receive(self, text_data=None, bytes_data=None): + self.log.info(f"Received message: {text_data}") + try: + text_data_json = json_loads(text_data) + message = text_data_json["message"] + + # Send message to room group + await self.channel_layer.group_send( + self.room_group_name, + {"type": "generic.event", "message": message, "name": "send_message_to_frontend", "event_id": 1} + ) + except JSONDecodeError as e: + await self.send(text_data=json_dumps({"message": "error", "error": "malformed json"})) + self.log.error(e) + except KeyError as e: + await self.send(text_data=json_dumps({"message": "error", "error": f"missing key: {str(e)}"})) + self.log.error(e) + except Exception as e: + await self.send(text_data=json_dumps({"message": "error", "error": "unknown error"})) + self.log.error(e) + raise e + + # Receive message from room group + async def generic_event(self, event): + self.log.info(f"Received event: {event}") + message = event["message"] + name = event["name"] + event_id = event["event_id"] + + # Send message to WebSocket + await self.send(text_data=json_dumps({"message": message, "name": name, "event_id": event_id})) diff --git a/core/notify_sessions/migrations/0001_initial.py b/core/notify_sessions/migrations/0001_initial.py new file mode 100644 index 0000000..c116acf --- /dev/null +++ b/core/notify_sessions/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.7 on 2023-12-09 02:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SystemEvent', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('type', models.CharField(choices=[('ticket_created', 'ticket_created'), ('ticket_updated', 'ticket_updated'), ('ticket_deleted', 'ticket_deleted'), ('item_created', 'item_created'), ('item_updated', 'item_updated'), ('item_deleted', 'item_deleted'), ('user_created', 'user_created'), ('event_created', 'event_created'), ('event_updated', 'event_updated'), ('event_deleted', 'event_deleted')], max_length=255)), + ('reference', models.IntegerField(blank=True, null=True)), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/core/notify_sessions/migrations/__init__.py b/core/notify_sessions/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notify_sessions/models.py b/core/notify_sessions/models.py new file mode 100644 index 0000000..095f1b5 --- /dev/null +++ b/core/notify_sessions/models.py @@ -0,0 +1,48 @@ +import logging + +from django.db import models +from asgiref.sync import sync_to_async +from channels.layers import get_channel_layer + +from authentication.models import ExtendedUser + + +class SystemEvent(models.Model): + TYPE_CHOICES = [('ticket_created', 'ticket_created'), + ('ticket_updated', 'ticket_updated'), + ('ticket_deleted', 'ticket_deleted'), + ('item_created', 'item_created'), + ('item_updated', 'item_updated'), + ('item_deleted', 'item_deleted'), + ('user_created', 'user_created'), + ('event_created', 'event_created'), + ('event_updated', 'event_updated'), + ('event_deleted', 'event_deleted'), ] + id = models.AutoField(primary_key=True) + timestamp = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(ExtendedUser, models.SET_NULL, null=True) + type = models.CharField(max_length=255, choices=TYPE_CHOICES) + reference = models.IntegerField(blank=True, null=True) + + +async def trigger_event(user, type, reference=None): + log = logging.getLogger('server.log') + log.info(f"Triggering event {type} for user {user} with reference {reference}") + try: + event = await sync_to_async(SystemEvent.objects.create, thread_sensitive=True)(user=user, type=type, + reference=reference) + channel_layer = get_channel_layer() + await channel_layer.group_send( + 'general', + { + 'type': 'generic.event', + 'name': 'send_message_to_frontend', + 'message': "event_trigered_from_views", + 'event_id': event.id, + } + ) + log.info(f"SystemEvent {event.id} triggered") + return event + except Exception as e: + log.error(e) + raise e diff --git a/core/notify_sessions/routing.py b/core/notify_sessions/routing.py new file mode 100644 index 0000000..1aa0190 --- /dev/null +++ b/core/notify_sessions/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .consumers import NotifyConsumer + +websocket_urlpatterns = [ + path('ws/2/notify/', NotifyConsumer.as_asgi()), +] diff --git a/core/notify_sessions/tests/__init__.py b/core/notify_sessions/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notify_sessions/tests/test_notify_socket.py b/core/notify_sessions/tests/test_notify_socket.py new file mode 100644 index 0000000..4e971f1 --- /dev/null +++ b/core/notify_sessions/tests/test_notify_socket.py @@ -0,0 +1,66 @@ +from django.test import TestCase +from channels.testing import WebsocketCommunicator + +from notify_sessions.consumers import NotifyConsumer +from asgiref.sync import async_to_sync + + +class NotifyWebsocketTestCase(TestCase): + + async def test_connect(self): + communicator = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/") + connected, subprotocol = await communicator.connect() + self.assertTrue(connected) + await communicator.disconnect() + + async def fut_send_message(self): + communicator = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/") + connected, subprotocol = await communicator.connect() + self.assertTrue(connected) + await communicator.send_json_to({ + "name": "foo", + "message": "bar", + }) + response = await communicator.receive_json_from() + await communicator.disconnect() + return response + + def test_send_message(self): + response = async_to_sync(self.fut_send_message)() + self.assertEqual(response["message"], "bar") + self.assertEqual(response["event_id"], 1) + self.assertEqual(response["name"], "send_message_to_frontend") + # events = SystemEvent.objects.all() + # self.assertEqual(len(events), 1) + # event = events[0] + # self.assertEqual(event.event_id, 1) + # self.assertEqual(event.name, "send_message_to_frontend") + # self.assertEqual(event.message, "bar") + + async def fut_send_and_receive_message(self): + communicator1 = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/") + communicator2 = WebsocketCommunicator(NotifyConsumer.as_asgi(), "/ws/2/notify/") + connected1, subprotocol1 = await communicator1.connect() + connected2, subprotocol2 = await communicator2.connect() + self.assertTrue(connected1) + self.assertTrue(connected2) + await communicator1.send_json_to({ + "name": "foo", + "message": "bar", + }) + response = await communicator2.receive_json_from() + await communicator1.disconnect() + await communicator2.disconnect() + return response + + def test_send_and_receive_message(self): + response = async_to_sync(self.fut_send_and_receive_message)() + self.assertEqual(response["message"], "bar") + self.assertEqual(response["event_id"], 1) + self.assertEqual(response["name"], "send_message_to_frontend") + # events = SystemEvent.objects.all() + # self.assertEqual(len(events), 1) + # event = events[0] + # self.assertEqual(event.event_id, 1) + # self.assertEqual(event.name, "send_message_to_frontend") + # self.assertEqual(event.message, "bar") diff --git a/core/requirements.dev.txt b/core/requirements.dev.txt new file mode 100644 index 0000000..ed037b6 --- /dev/null +++ b/core/requirements.dev.txt @@ -0,0 +1,76 @@ +aiodns==3.2.0 +aiohttp==3.9.5 +aiosignal==1.3.1 +aiosmtpd==1.4.4.post2 +aiosmtplib==3.0.1 +anyio==4.1.0 +asgiref==3.7.2 +asynctest==0.7.1 +atpublic==4.0 +attrs==23.1.0 +autobahn==23.6.2 +Automat==22.10.0 +beautifulsoup4==4.12.2 +bs4==0.0.1 +certifi==2023.11.17 +#cffi==1.16.0 +channels==4.0.0 +channels-redis==4.1.0 +charset-normalizer==3.3.2 +click==8.1.7 +constantly==23.10.4 +coreapi==2.3.3 +coreschema==0.0.4 +coverage==7.3.2 +cryptography==41.0.5 +daphne==4.0.0 +Django==4.2.7 +django-async-test==0.2.2 +django-extensions==3.2.3 +django-rest-knox==4.2.0 +django-soft-delete==0.9.21 +djangorestframework==3.14.0 +drf-yasg==1.21.7 +frozenlist==1.4.1 +h11==0.14.0 +hyperlink==21.0.0 +idna==3.4 +incremental==22.10.0 +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 +multidict==6.0.5 +openapi-codec==1.3.2 +packaging==23.2 +Pillow==11.1.0 +pyasn1==0.5.1 +pyasn1-modules==0.3.0 +pycares==4.4.0 +pycparser==2.21 +pyOpenSSL==23.3.0 +python-dotenv==1.0.0 +pytz==2023.3.post1 +PyYAML==6.0.1 +redis==5.0.1 +requests==2.31.0 +sdnotify==0.3.2 +service-identity==23.1.0 +setproctitle==1.3.3 +six==1.16.0 +sniffio==1.3.0 +soupsieve==2.5 +sqlparse==0.4.4 +Twisted==23.10.0 +txaio==23.1.1 +typing_extensions==4.8.0 +uritemplate==4.1.1 +urllib3==2.1.0 +uvicorn==0.24.0.post1 +websockets==12.0 +yarl==1.9.4 +zope.interface==6.1 +django-prometheus==2.3.1 +prometheus_client==0.21.0 diff --git a/core/requirements.prod.txt b/core/requirements.prod.txt new file mode 100644 index 0000000..ee69fe7 --- /dev/null +++ b/core/requirements.prod.txt @@ -0,0 +1,45 @@ +aiodns==3.2.0 +aiohttp==3.9.5 +aiosignal==1.3.1 +aiosmtpd==1.4.4.post2 +aiosmtplib==3.0.1 +asgiref==3.7.2 +attrs==23.1.0 +beautifulsoup4==4.12.2 +bs4==0.0.1 +certifi==2023.11.17 +channels==4.0.0 +channels-redis==4.1.0 +coreapi==2.3.3 +coreschema==0.0.4 +Django==4.2.7 +django-extensions==3.2.3 +django-mysql==4.12.0 +django-rest-knox==4.2.0 +django-soft-delete==0.9.21 +djangorestframework==3.14.0 +drf-yasg==1.21.7 +MarkupSafe==2.1.3 +msgpack==1.0.7 +msgpack-python==0.5.6 +mysqlclient==2.2.0 +openapi-codec==1.3.2 +packaging==23.2 +Pillow==10.1.0 +python-dotenv==1.0.0 +pytz==2023.3.post1 +PyYAML==6.0.1 +requests==2.31.0 +sdnotify==0.3.2 +setproctitle==1.3.3 +sniffio==1.3.0 +soupsieve==2.5 +sqlparse==0.4.4 +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 +django-prometheus==2.3.1 +prometheus_client==0.21.0 diff --git a/core/server.py b/core/server.py new file mode 100644 index 0000000..d08b595 --- /dev/null +++ b/core/server.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +import logging +import os + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +import django +import uvicorn + +django.setup() + +from helper import init_loop +from mail.protocol import LMTPHandler +from mail.socket import UnixSocketLMTPController + + +class UvicornServer(uvicorn.Server): + def install_signal_handlers(self): + pass + + +async def web(loop): + log_config = uvicorn.config.LOGGING_CONFIG + log_config["handlers"]["default"] = {"class": "logging.FileHandler", "filename": "web.log", "formatter": "default"} + log_config["handlers"]["access"] = {"class": "logging.FileHandler", "filename": "web-access.log", + "formatter": "access"} + config = uvicorn.Config("core.asgi:application", uds="web.sock", log_config=log_config) + server = UvicornServer(config=config) + await server.serve() + + +async def lmtp(loop): + import grp + log = logging.getLogger('mail.log') + log.addHandler(logging.FileHandler('mail.log')) + # log.setLevel(logging.WARNING) + log.setLevel(logging.INFO) + log.info("Starting LMTP server") + server = await UnixSocketLMTPController(LMTPHandler(), unix_socket='lmtp.sock', loop=loop).serve() + + addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets) + log.info(f'Serving on {addrs}') + + try: + os.chmod('lmtp.sock', 0o775) + current_uid = os.getuid() + posix_gid = grp.getgrnam('postfix').gr_gid + os.chown('lmtp.sock', current_uid, posix_gid) + except Exception as e: + log.error(e) + + async with server: + await server.serve_forever() + log.info("LMTP done") + + +def main(): + import sdnotify + import setproctitle + import os + setproctitle.setproctitle("c3lf-sys3") + log = logging.getLogger('server.log') + log.addHandler(logging.FileHandler('server.log')) + log.setLevel(logging.DEBUG) + log.info("Starting server") + loop = init_loop() + loop.create_task(web(loop)) + # loop.create_task(tcp(loop)) + loop.create_task(lmtp(loop)) + n = sdnotify.SystemdNotifier() + n.notify("READY=1") + log.info("Server ready") + try: + loop.run_forever() + finally: + loop.close() + try: + os.remove("lmtp.sock") + except Exception as e: + log.error(e) + try: + os.remove("web.sock") + except Exception as e: + log.error(e) + log.error(e) + logging.info("Server stopped") + + logging.shutdown() + + +if __name__ == '__main__': + main() diff --git a/core/tickets/__init__.py b/core/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/admin.py b/core/tickets/admin.py new file mode 100644 index 0000000..d842482 --- /dev/null +++ b/core/tickets/admin.py @@ -0,0 +1,35 @@ +from django.contrib import admin + +from tickets.models import IssueThread, Comment, StateChange, Assignment, ItemRelation, ShippingVoucher + + +class IssueThreadAdmin(admin.ModelAdmin): + pass + + +class CommentAdmin(admin.ModelAdmin): + pass + + +class StateChangeAdmin(admin.ModelAdmin): + pass + + +class AssignmentAdmin(admin.ModelAdmin): + pass + + +class ItemRelationAdmin(admin.ModelAdmin): + pass + + +class ShippingVouchersAdmin(admin.ModelAdmin): + pass + + +admin.site.register(IssueThread, IssueThreadAdmin) +admin.site.register(Comment, CommentAdmin) +admin.site.register(StateChange, StateChangeAdmin) +admin.site.register(Assignment, AssignmentAdmin) +admin.site.register(ItemRelation, ItemRelationAdmin) +admin.site.register(ShippingVoucher, ShippingVouchersAdmin) diff --git a/core/tickets/api_v2.py b/core/tickets/api_v2.py new file mode 100644 index 0000000..6e34465 --- /dev/null +++ b/core/tickets/api_v2.py @@ -0,0 +1,229 @@ +from base64 import b64decode + +from django.urls import re_path +from django.contrib.auth.decorators import permission_required +from rest_framework import routers, viewsets, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from core.settings import MAIL_DOMAIN +from inventory.models import Event +from mail.models import Email +from mail.protocol import send_smtp, make_reply, collect_references +from notify_sessions.models import SystemEvent +from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher, ItemRelation +from tickets.serializers import IssueSerializer, CommentSerializer, ShippingVoucherSerializer, SearchResultSerializer +from tickets.shared_serializers import RelationSerializer + + +class IssueViewSet(viewsets.ModelViewSet): + serializer_class = IssueSerializer + + def get_queryset(self): + queryset = IssueThread.objects.all() + serializer = self.get_serializer_class() + if hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'prefetch_related_fields'): + queryset = queryset.prefetch_related(*serializer.Meta.prefetch_related_fields) + return queryset + + +class RelationViewSet(viewsets.ModelViewSet): + serializer_class = RelationSerializer + queryset = ItemRelation.objects.all() + + +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = CommentSerializer + queryset = Comment.objects.all() + + +class ShippingVoucherViewSet(viewsets.ModelViewSet): + serializer_class = ShippingVoucherSerializer + queryset = ShippingVoucher.objects.all() + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@permission_required('tickets.add_issuethread', raise_exception=True) +def reply(request, pk): + issue = IssueThread.objects.get(pk=pk) + # email = issue.reply(request.data['body']) # TODO evaluate if this is a useful abstraction + references = collect_references(issue) + first_mail = Email.objects.filter(issue_thread=issue, recipient__endswith='@' + MAIL_DOMAIN).order_by( + 'timestamp').first() + if not first_mail: + return Response({'status': 'error', 'message': 'no previous mail found, reply would not make sense.'}, + status=status.HTTP_400_BAD_REQUEST) + mail = Email.objects.create( + issue_thread=issue, + sender=first_mail.recipient, + recipient=first_mail.sender, + subject=f'Re: {issue.name} [#{issue.short_uuid()}]', + body=request.data['message'], + in_reply_to=first_mail.reference, + ) + async_to_sync(send_smtp)(make_reply(mail, references)) + + return Response({'status': 'ok'}, status=status.HTTP_201_CREATED) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@permission_required('tickets.add_issuethread_manual', raise_exception=True) +def manual_ticket(request, event_slug): + if 'name' not in request.data: + return Response({'status': 'error', 'message': 'missing name'}, status=status.HTTP_400_BAD_REQUEST) + if 'sender' not in request.data: + return Response({'status': 'error', 'message': 'missing sender'}, status=status.HTTP_400_BAD_REQUEST) + if 'recipient' not in request.data: + return Response({'status': 'error', 'message': 'missing recipient'}, status=status.HTTP_400_BAD_REQUEST) + if 'body' not in request.data: + return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST) + + event = None + if event_slug != 'none': + try: + event = Event.objects.get(slug=event_slug) + except: + return Response({'status': 'error', 'message': 'invalid event'}, status=status.HTTP_400_BAD_REQUEST) + + issue = IssueThread.objects.create( + name=request.data['name'], + event=event, + manually_created=True, + ) + email = Email.objects.create( + issue_thread=issue, + sender=request.data['sender'], + recipient=request.data['recipient'], + subject=request.data['name'], + body=request.data['body'], + ) + + return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_available_states(request): + def get_state_choices(): + for state in STATE_CHOICES: + yield {'value': list(state)[0], 'text': list(state)[1]} + + return Response(get_state_choices()) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@permission_required('tickets.add_comment', raise_exception=True) +def add_comment(request, pk): + issue = IssueThread.objects.get(pk=pk) + if 'comment' not in request.data or request.data['comment'] == '': + return Response({'status': 'error', 'message': 'missing comment'}, status=status.HTTP_400_BAD_REQUEST) + comment = Comment.objects.create( + issue_thread=issue, + comment=request.data['comment'], + ) + return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED) + + +def filter_issues(issues, query): + query_tokens = query.lower().split(' ') + matches = [] + 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(): + 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} + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def search_issues(request, event_slug, query): + try: + 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')) + return Response(SearchResultSerializer(items, many=True).data) + except Event.DoesNotExist: + return Response(status=404) + + +router = routers.SimpleRouter() +router.register(r'tickets', IssueViewSet, basename='issues') +router.register(r'matches', RelationViewSet, basename='matches') +router.register(r'shipping_vouchers', ShippingVoucherViewSet, basename='shipping_vouchers') + +urlpatterns = ([ + re_path(r'tickets/states/$', get_available_states, name='get_available_states'), + re_path(r'^tickets/(?P\d+)/reply/$', reply, name='reply'), + re_path(r'^tickets/(?P\d+)/comment/$', add_comment, name='add_comment'), + re_path(r'^(?P[\w-]+)/tickets/manual/$', manual_ticket, name='manual_ticket'), + re_path(r'^(?P[\w-]+)/tickets/(?P[-A-Za-z0-9+/]*={0,3})/$', search_issues, + name='search_issues'), + ] + router.urls) diff --git a/core/tickets/migrations/0001_initial.py b/core/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..475d70c --- /dev/null +++ b/core/tickets/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.7 on 2023-12-09 02:13 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='IssueThread', + fields=[ + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('state', models.CharField(default='new', max_length=255)), + ('assigned_to', models.CharField(max_length=255, null=True)), + ('last_activity', models.DateTimeField(auto_now=True)), + ], + options={ + 'permissions': [('send_mail', 'Can send mail')], + }, + ), + migrations.CreateModel( + name='StateChange', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('state', models.CharField(max_length=255)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state_changes', to='tickets.issuethread')), + ], + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('comment', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tickets.issuethread')), + ], + ), + ] diff --git a/core/tickets/migrations/0002_alter_issuethread_options_and_more.py b/core/tickets/migrations/0002_alter_issuethread_options_and_more.py new file mode 100644 index 0000000..3c1e4a7 --- /dev/null +++ b/core/tickets/migrations/0002_alter_issuethread_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2023-12-22 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='issuethread', + options={'permissions': [('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually')]}, + ), + migrations.AddField( + model_name='issuethread', + name='manually_created', + field=models.BooleanField(default=False), + ), + ] diff --git a/core/tickets/migrations/0003_alter_issuethread_state.py b/core/tickets/migrations/0003_alter_issuethread_state.py new file mode 100644 index 0000000..dd2c9bc --- /dev/null +++ b/core/tickets/migrations/0003_alter_issuethread_state.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.7 on 2023-12-28 20:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('tickets', '0002_alter_issuethread_options_and_more'), + ] + + def convert_state(apps, schema_editor): + IssueThread = apps.get_model('tickets', 'IssueThread') + for issue in IssueThread.objects.all(): + if issue.state == 'new': + issue.state = 'pending_new' + issue.save() + StateChange = apps.get_model('tickets', 'StateChange') + for change in StateChange.objects.all(): + if change.state == 'new': + change.state = 'pending_new' + change.save() + + operations = [ + migrations.AlterField( + model_name='issuethread', + 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'), ('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')], default='pending_new', max_length=32, verbose_name='state'), + ), + migrations.RunPython(convert_state), + ] diff --git a/core/tickets/migrations/0004_remove_issuethread_state_alter_statechange_state.py b/core/tickets/migrations/0004_remove_issuethread_state_alter_statechange_state.py new file mode 100644 index 0000000..7fda407 --- /dev/null +++ b/core/tickets/migrations/0004_remove_issuethread_state_alter_statechange_state.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.7 on 2023-12-29 22:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0003_alter_issuethread_state'), + ] + + operations = [ + migrations.RemoveField( + model_name='issuethread', + name='state', + ), + 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'), ('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')], default='pending_new', max_length=255), + ), + ] diff --git a/core/tickets/migrations/0005_remove_issuethread_last_activity.py b/core/tickets/migrations/0005_remove_issuethread_last_activity.py new file mode 100644 index 0000000..37a03ef --- /dev/null +++ b/core/tickets/migrations/0005_remove_issuethread_last_activity.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-01-10 19:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0004_remove_issuethread_state_alter_statechange_state'), + ] + + operations = [ + migrations.RemoveField( + model_name='issuethread', + name='last_activity', + ), + ] diff --git a/core/tickets/migrations/0006_issuethread_uuid.py b/core/tickets/migrations/0006_issuethread_uuid.py new file mode 100644 index 0000000..6618bbe --- /dev/null +++ b/core/tickets/migrations/0006_issuethread_uuid.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.7 on 2024-01-12 21:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('tickets', '0005_remove_issuethread_last_activity'), + ] + + def set_uuid(apps, schema_editor): + import uuid + IssueThread = apps.get_model('tickets', 'IssueThread') + for issue_thread in IssueThread.objects.all(): + issue_thread.uuid = str(uuid.uuid4()) + issue_thread.save() + + operations = [ + migrations.AddField( + model_name='issuethread', + name='uuid', + field=models.CharField(max_length=255, null=True), + ), + migrations.RunPython(set_uuid), + migrations.AlterField( + model_name='issuethread', + name='uuid', + field=models.CharField(max_length=255, unique=True, null=False, blank=False), + ), + ] diff --git a/core/tickets/migrations/0007_alter_statechange_state.py b/core/tickets/migrations/0007_alter_statechange_state.py new file mode 100644 index 0000000..6a105a7 --- /dev/null +++ b/core/tickets/migrations/0007_alter_statechange_state.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-01-19 20:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0006_issuethread_uuid'), + ] + + 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'), ('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), + ), + ] diff --git a/core/tickets/migrations/0008_alter_issuethread_options_and_more.py b/core/tickets/migrations/0008_alter_issuethread_options_and_more.py new file mode 100644 index 0000000..788f9f6 --- /dev/null +++ b/core/tickets/migrations/0008_alter_issuethread_options_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.7 on 2024-01-22 16:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0007_alter_statechange_state'), + ] + + operations = [ + migrations.AlterModelOptions( + name='issuethread', + options={'permissions': [('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually'), ('assign_issuethread', 'Can assign issue thread')]}, + ), + migrations.RemoveField( + model_name='issuethread', + name='assigned_to', + ), + migrations.CreateModel( + name='Assignment', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('assigned_to', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)), + ('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='tickets.issuethread')), + ], + ), + ] diff --git a/core/tickets/migrations/0009_shippingvoucher.py b/core/tickets/migrations/0009_shippingvoucher.py new file mode 100644 index 0000000..a857457 --- /dev/null +++ b/core/tickets/migrations/0009_shippingvoucher.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.7 on 2024-06-23 00:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0008_alter_issuethread_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ShippingVoucher', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('voucher', models.CharField(max_length=255)), + ('type', models.CharField(max_length=255)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('used_at', models.DateTimeField(null=True)), + ('issue_thread', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='shipping_vouchers', to='tickets.issuethread')), + ], + ), + ] diff --git a/core/tickets/migrations/0010_issuethread_event_itemrelation_and_more.py b/core/tickets/migrations/0010_issuethread_event_itemrelation_and_more.py new file mode 100644 index 0000000..06ec4fd --- /dev/null +++ b/core/tickets/migrations/0010_issuethread_event_itemrelation_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.7 on 2024-06-23 02:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_alter_event_created_at_alter_item_created_at'), + ('tickets', '0009_shippingvoucher'), + ] + + operations = [ + migrations.AddField( + model_name='issuethread', + name='event', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_threads', to='inventory.event'), + ), + migrations.CreateModel( + name='ItemRelation', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('possible', 'Possible'), ('confirmed', 'Confirmed'), ('discarded', 'Discarded'), ('provided', 'Provided')], default='possible', max_length=255)), + ('issue_thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_relations', to='tickets.issuethread')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issues', to='inventory.item')), + ], + ), + migrations.AddField( + model_name='issuethread', + name='related_items', + field=models.ManyToManyField(through='tickets.ItemRelation', to='inventory.item'), + ), + ] diff --git a/core/tickets/migrations/0011_train_old_spam.py b/core/tickets/migrations/0011_train_old_spam.py new file mode 100644 index 0000000..206cbb4 --- /dev/null +++ b/core/tickets/migrations/0011_train_old_spam.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.7 on 2024-06-23 02:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('mail', '0006_email_raw_file'), + ('tickets', '0010_issuethread_event_itemrelation_and_more'), + ] + + def train_old_mails(apps, schema_editor): + from tickets.models import IssueThread + for t in IssueThread.objects.all(): + try: + state = t.state + i = 0 + for e in t.emails.all(): + if e.raw_file: + if state == 'closed_spam' and i == 0: + e.train_spam() + else: + e.train_ham() + i += 1 + except: + pass + + operations = [ + migrations.RunPython(train_old_mails), + ] diff --git a/core/tickets/migrations/0012_remove_issuethread_related_items_and_more.py b/core/tickets/migrations/0012_remove_issuethread_related_items_and_more.py new file mode 100644 index 0000000..d8a24c7 --- /dev/null +++ b/core/tickets/migrations/0012_remove_issuethread_related_items_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.7 on 2024-11-20 23:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0006_alter_event_table'), + ('tickets', '0011_train_old_spam'), + ] + + operations = [ + migrations.RemoveField( + model_name='issuethread', + name='related_items', + ), + migrations.AlterField( + model_name='itemrelation', + name='issue_thread', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_relation_changes', to='tickets.issuethread'), + ), + migrations.AlterField( + model_name='itemrelation', + name='item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_relation_changes', to='inventory.item'), + ), + ] diff --git a/core/tickets/migrations/0013_alter_statechange_state.py b/core/tickets/migrations/0013_alter_statechange_state.py new file mode 100644 index 0000000..6a99ce5 --- /dev/null +++ b/core/tickets/migrations/0013_alter_statechange_state.py @@ -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), + ), + ] diff --git a/core/tickets/migrations/__init__.py b/core/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/models.py b/core/tickets/models.py new file mode 100644 index 0000000..794c8e4 --- /dev/null +++ b/core/tickets/models.py @@ -0,0 +1,179 @@ +from itertools import groupby + +from django.utils import timezone +from django.db import models +from django_softdelete.models import SoftDeleteModel + +from authentication.models import ExtendedUser +from inventory.models import Event, Item +from django.db.models.signals import post_save, pre_save +from django.dispatch import receiver + +STATE_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'), +) + +RELATION_STATUS_CHOICES = ( + ('possible', 'Possible'), + ('confirmed', 'Confirmed'), + ('discarded', 'Discarded'), + ('provided', 'Provided'), +) + + +class IssueThread(SoftDeleteModel): + id = models.AutoField(primary_key=True) + uuid = models.CharField(max_length=255, unique=True, null=False, blank=False) + name = models.CharField(max_length=255) + 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] + + @property + def state(self): + try: + state_changes = sorted(self.state_changes.all(), key=lambda x: x.timestamp, reverse=True) + if state_changes: + return state_changes[0].state + else: + return None + except AttributeError: + return 'none' + + @state.setter + def state(self, value): + if self.state == value: + return + self.state_changes.create(state=value) + if value == 'closed_spam' and self.emails.exists(): + self.emails.first().train_spam() + + @property + def assigned_to(self): + try: + assignments = sorted(self.assignments.all(), key=lambda x: x.timestamp, reverse=True) + if assignments: + return assignments[0].assigned_to + else: + return None + except AttributeError: + return None + + @assigned_to.setter + def assigned_to(self, value): + if self.assigned_to == value: + return + self.assignments.create(assigned_to=value) + + @property + def related_items(self): + groups = groupby(self.item_relation_changes.all(), lambda rel: rel.item.id) + return [sorted(v, key=lambda r: r.timestamp)[0].item for k, v in groups] + + def __str__(self): + return '[' + str(self.id) + '][' + self.short_uuid() + '] ' + self.name + + class Meta: + permissions = [ + ('send_mail', 'Can send mail'), + ('add_issuethread_manual', 'Can add issue thread manually'), + ('assign_issuethread', 'Can assign issue thread'), + ] + + +@receiver(pre_save, sender=IssueThread) +def set_uuid(sender, instance, **kwargs): + import uuid + if instance.uuid is None or instance.uuid == '': + instance.uuid = str(uuid.uuid4()) + + +@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') + + +class Comment(models.Model): + id = models.AutoField(primary_key=True) + issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='comments') + comment = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return str(self.issue_thread) + ' comment #' + str(self.id) + + +class StateChange(models.Model): + id = models.AutoField(primary_key=True) + issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='state_changes') + state = models.CharField(max_length=255, choices=STATE_CHOICES, default='pending_new') + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return str(self.issue_thread) + ' state change to ' + self.state + + +class Assignment(models.Model): + id = models.AutoField(primary_key=True) + issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='assignments') + assigned_to = models.ForeignKey(ExtendedUser, on_delete=models.CASCADE, related_name='assigned_tickets') + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return str(self.issue_thread) + ' assigned to ' + self.assigned_to.username + + +class ItemRelation(models.Model): + id = models.AutoField(primary_key=True) + issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='item_relation_changes') + item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='issue_relation_changes') + timestamp = models.DateTimeField(auto_now_add=True) + status = models.CharField(max_length=255, choices=RELATION_STATUS_CHOICES, default='possible') + + def __str__(self): + return str(self.issue_thread) + ' related to ' + str(self.item) + + +class ShippingVoucher(models.Model): + id = models.AutoField(primary_key=True) + issue_thread = models.ForeignKey(IssueThread, on_delete=models.CASCADE, related_name='shipping_vouchers', null=True) + voucher = models.CharField(max_length=255) + type = models.CharField(max_length=255) + timestamp = models.DateTimeField(auto_now_add=True) + used_at = models.DateTimeField(null=True) + + def __str__(self): + return self.voucher + ' (' + self.type + ')' + + def save(self, *args, **kwargs): + if self.used_at is None and self.issue_thread is not None: + self.used_at = timezone.now() + super().save(*args, **kwargs) diff --git a/core/tickets/serializers.py b/core/tickets/serializers.py new file mode 100644 index 0000000..ff695b1 --- /dev/null +++ b/core/tickets/serializers.py @@ -0,0 +1,150 @@ +from rest_framework import serializers + +from authentication.models import ExtendedUser +from inventory.models import Event +from inventory.shared_serializers import BasicItemSerializer +from mail.api_v2 import AttachmentSerializer +from tickets.models import IssueThread, Comment, STATE_CHOICES, ShippingVoucher +from tickets.shared_serializers import BasicIssueSerializer + + +class CommentSerializer(serializers.ModelSerializer): + + def validate(self, attrs): + if 'comment' not in attrs or attrs['comment'] == '': + raise serializers.ValidationError('comment cannot be empty') + return attrs + + class Meta: + model = Comment + fields = ('id', 'comment', 'timestamp', 'issue_thread') + + +class StateSerializer(serializers.Serializer): + text = serializers.SerializerMethodField() + value = serializers.SerializerMethodField() + + def get_text(self, obj): + return obj['text'] + + def get_value(self, obj): + return obj['value'] + + +class ShippingVoucherSerializer(serializers.ModelSerializer): + class Meta: + model = ShippingVoucher + fields = ('id', 'voucher', 'type', 'timestamp', 'issue_thread', 'used_at') + read_only_fields = ('id', 'timestamp', 'used_at') + + +class IssueSerializer(BasicIssueSerializer): + timeline = serializers.SerializerMethodField() + last_activity = serializers.SerializerMethodField() + related_items = BasicItemSerializer(many=True, read_only=True) + + class Meta: + model = IssueThread + fields = ('id', 'timeline', 'name', 'state', 'assigned_to', 'last_activity', 'uuid', 'related_items', 'event') + read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items') + prefetch_related_fields = ['state_changes', 'comments', 'emails', 'emails__attachments', 'assignments', + 'item_relation_changes', 'shipping_vouchers', 'item_relation_changes__item', + 'item_relation_changes__item__container_history', 'event', + 'item_relation_changes__item__container_history__container', + 'item_relation_changes__item__files', 'item_relation_changes__item__event', + 'item_relation_changes__item__event'] + + def to_internal_value(self, data): + ret = super().to_internal_value(data) + if 'state' in data: + ret['state'] = data['state'] + return ret + + def validate(self, attrs): + if 'state' in attrs: + if attrs['state'] not in [x[0] for x in STATE_CHOICES]: + raise serializers.ValidationError('invalid state') + return attrs + + @staticmethod + def get_last_activity(self): + try: + last_state_change = max( + [t.timestamp for t in self.state_changes.all()]) if self.state_changes.exists() else None + last_comment = max([t.timestamp for t in self.comments.all()]) if self.comments.exists() else None + last_mail = max([t.timestamp for t in self.emails.all()]) if self.emails.exists() else None + last_assignment = max([t.timestamp for t in self.assignments.all()]) if self.assignments.exists() else None + + last_relation = max([t.timestamp for t in + self.item_relation_changes.all()]) if self.item_relation_changes.exists() else None + args = [x for x in [last_state_change, last_comment, last_mail, last_assignment, last_relation] if + x is not None] + return max(args) + except AttributeError: + return None + + @staticmethod + def get_timeline(obj): + timeline = [] + for comment in obj.comments.all(): + timeline.append({ + 'type': 'comment', + 'id': comment.id, + 'timestamp': comment.timestamp, + 'comment': comment.comment, + }) + for state_change in obj.state_changes.all(): + timeline.append({ + 'type': 'state', + 'id': state_change.id, + 'timestamp': state_change.timestamp, + 'state': state_change.state, + }) + for email in obj.emails.all(): + timeline.append({ + 'type': 'mail', + 'id': email.id, + 'timestamp': email.timestamp, + 'sender': email.sender, + 'recipient': email.recipient, + 'subject': email.subject, + 'body': email.body, + 'attachments': AttachmentSerializer(email.attachments.all(), many=True).data, + }) + for assignment in obj.assignments.all(): + timeline.append({ + 'type': 'assignment', + 'id': assignment.id, + 'timestamp': assignment.timestamp, + 'assigned_to': assignment.assigned_to.username, + }) + for relation in (obj.item_relation_changes.all()): + timeline.append({ + 'type': 'item_relation', + 'id': relation.id, + 'status': relation.status, + 'timestamp': relation.timestamp, + 'item': BasicItemSerializer(relation.item).data, + }) + for shipping_voucher in obj.shipping_vouchers.all(): + timeline.append({ + 'type': 'shipping_voucher', + 'id': shipping_voucher.id, + 'timestamp': shipping_voucher.used_at, + 'voucher': shipping_voucher.voucher, + 'voucher_type': shipping_voucher.type, + }) + return sorted(timeline, key=lambda x: x['timestamp']) + + +class SearchResultSerializer(serializers.Serializer): + search_score = serializers.IntegerField() + search_matches = serializers.ListField(child=serializers.DictField()) + issue = IssueSerializer() + + def to_representation(self, instance): + return {**IssueSerializer(instance['issue']).data, 'search_score': instance['search_score'], + 'search_matches': instance['search_matches']} + + class Meta: + model = IssueThread diff --git a/core/tickets/shared_serializers.py b/core/tickets/shared_serializers.py new file mode 100644 index 0000000..3d46013 --- /dev/null +++ b/core/tickets/shared_serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + +from authentication.models import ExtendedUser +from inventory.models import Event +from tickets.models import IssueThread, ItemRelation + + +class RelationSerializer(serializers.ModelSerializer): + class Meta: + model = ItemRelation + fields = ('id', 'status', 'timestamp', 'item', 'issue_thread') + read_only_fields = ('id', 'timestamp') + + +class BasicIssueSerializer(serializers.ModelSerializer): + assigned_to = serializers.SlugRelatedField(slug_field='username', queryset=ExtendedUser.objects.all(), + allow_null=True, required=False) + event = serializers.SlugRelatedField(slug_field='slug', queryset=Event.objects.all(), + allow_null=True, required=False) + + class Meta: + model = IssueThread + fields = ('id', 'name', 'state', 'assigned_to', 'uuid', 'event') + read_only_fields = ('id', 'timeline', 'last_activity', 'uuid', 'related_items') diff --git a/core/tickets/tests/__init__.py b/core/tickets/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/tests/v2/__init__.py b/core/tickets/tests/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/tickets/tests/v2/test_matches.py b/core/tickets/tests/v2/test_matches.py new file mode 100644 index 0000000..7bd7a52 --- /dev/null +++ b/core/tickets/tests/v2/test_matches.py @@ -0,0 +1,149 @@ +from datetime import datetime, timedelta + +from django.test import TestCase, Client + +from authentication.models import ExtendedUser +from inventory.models import Event, Container, Item +from mail.models import Email, EmailAttachment +from tickets.models import IssueThread, StateChange, Comment, ItemRelation +from django.contrib.auth.models import Permission +from knox.models import AuthToken + +from base64 import b64encode + + +class IssueItemMatchApiTest(TestCase): + + def setUp(self): + super().setUp() + 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='evt') + 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]}) + now = datetime.now() + self.issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + self.mail1 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=self.issue, + timestamp=now, + ) + self.comment = Comment.objects.create( + issue_thread=self.issue, + comment="test", + timestamp=now + timedelta(seconds=3), + ) + self.match = ItemRelation.objects.create( + issue_thread=self.issue, + item=self.item, + timestamp=now + timedelta(seconds=5), + ) + + def test_issues(self): + response = self.client.get('/api/2/tickets/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], self.issue.id) + self.assertEqual(response.json()[0]['name'], "test issue") + self.assertEqual(response.json()[0]['state'], "pending_new") + self.assertEqual(response.json()[0]['event'], "evt") + self.assertEqual(response.json()[0]['assigned_to'], None) + self.assertEqual(response.json()[0]['uuid'], self.issue.uuid) + self.assertEqual(response.json()[0]['last_activity'], self.match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(len(response.json()[0]['timeline']), 4) + self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state') + self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail') + self.assertEqual(response.json()[0]['timeline'][2]['type'], 'comment') + self.assertEqual(response.json()[0]['timeline'][1]['id'], self.mail1.id) + self.assertEqual(response.json()[0]['timeline'][2]['id'], self.comment.id) + self.assertEqual(response.json()[0]['timeline'][0]['state'], 'pending_new') + self.assertEqual(response.json()[0]['timeline'][1]['sender'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['recipient'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['subject'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['body'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['timestamp'], + self.mail1.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]['timestamp'], + self.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'], + self.match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(response.json()[0]['timeline'][3]['item']['description'], "foo") + self.assertEqual(response.json()[0]['timeline'][3]['item']['event'], "evt") + self.assertEqual(response.json()[0]['timeline'][3]['item']['box'], "box1") + self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo") + self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt") + self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1") + + def test_members(self): + response = self.client.get(f'/api/2/{self.event.slug}/item/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], self.item.id) + self.assertEqual(response.json()[0]['description'], 'foo') + self.assertEqual(response.json()[0]['box'], 'box1') + self.assertEqual(response.json()[0]['cid'], self.box.id) + 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]['related_issues']), 1) + self.assertEqual(response.json()[0]['related_issues'][0]['id'], self.issue.id) + self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue") + + def test_add_match(self): + response = self.client.get('/api/2/matches/') + self.assertEqual(1, len(response.json())) + item = Item.objects.create(container=self.box, event=self.event, description='1') + issue = IssueThread.objects.create(name="test issue", event=self.event) + + response = self.client.post(f'/api/2/matches/', + {'item': item.id, 'issue_thread': issue.id}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + + response = self.client.get('/api/2/matches/') + self.assertEqual(2, len(response.json())) + + response = self.client.get('/api/2/tickets/') + self.assertEqual(4, len(response.json()[0]['timeline'])) + self.assertEqual('item_relation', response.json()[0]['timeline'][3]['type']) + self.assertEqual('possible', response.json()[0]['timeline'][3]['status']) + self.assertEqual(1, len(response.json()[0]['related_items'])) + + def test_change_match_state(self): + response = self.client.get('/api/2/matches/') + self.assertEqual(1, len(response.json())) + + response = self.client.get('/api/2/tickets/') + self.assertEqual(4, len(response.json()[0]['timeline'])) + self.assertEqual('item_relation', response.json()[0]['timeline'][3]['type']) + self.assertEqual('possible', response.json()[0]['timeline'][3]['status']) + self.assertEqual(1, len(response.json()[0]['related_items'])) + + response = self.client.post(f'/api/2/matches/', + {'item': self.item.id, 'issue_thread': self.issue.id, 'status': 'confirmed'}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['status'], 'confirmed') + self.assertEqual(response.json()['id'], 2) + + response = self.client.get('/api/2/matches/') + self.assertEqual(2, len(response.json())) + + response = self.client.get('/api/2/tickets/') + self.assertEqual(5, len(response.json()[0]['timeline'])) + self.assertEqual('item_relation', response.json()[0]['timeline'][3]['type']) + self.assertEqual('possible', response.json()[0]['timeline'][3]['status']) + self.assertEqual('item_relation', response.json()[0]['timeline'][4]['type']) + self.assertEqual('confirmed', response.json()[0]['timeline'][4]['status']) + self.assertEqual(1, len(response.json()[0]['related_items'])) diff --git a/core/tickets/tests/v2/test_shipping_vouchers.py b/core/tickets/tests/v2/test_shipping_vouchers.py new file mode 100644 index 0000000..45fa245 --- /dev/null +++ b/core/tickets/tests/v2/test_shipping_vouchers.py @@ -0,0 +1,41 @@ +from datetime import datetime, timedelta + +from django.test import TestCase, Client + +from authentication.models import ExtendedUser +from mail.models import Email, EmailAttachment +from tickets.models import IssueThread, StateChange, Comment, ShippingVoucher +from django.contrib.auth.models import Permission +from knox.models import AuthToken + + +class ShippingVoucherApiTest(TestCase): + + def setUp(self): + super().setUp() + self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') + self.user.user_permissions.add(*Permission.objects.all()) + self.user.save() + self.token = AuthToken.objects.create(user=self.user) + self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) + + def test_issues_empty(self): + response = self.client.get('/api/2/shipping_vouchers/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_issues_list(self): + ShippingVoucher.objects.create(voucher='1234', type='2kg-eu') + response = self.client.get('/api/2/shipping_vouchers/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()[0]['voucher'], '1234') + self.assertEqual(response.json()[0]['used_at'], None) + self.assertEqual(response.json()[0]['issue_thread'], None) + self.assertEqual(response.json()[0]['type'], '2kg-eu') + + def test_issues_create(self): + response = self.client.post('/api/2/shipping_vouchers/', {'voucher': '1234', 'type': '2kg-eu'}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['voucher'], '1234') + self.assertEqual(response.json()['used_at'], None) + self.assertEqual(response.json()['issue_thread'], None) diff --git a/core/tickets/tests/v2/test_tickets.py b/core/tickets/tests/v2/test_tickets.py new file mode 100644 index 0000000..d7bb346 --- /dev/null +++ b/core/tickets/tests/v2/test_tickets.py @@ -0,0 +1,491 @@ +from datetime import datetime, timedelta + +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 +from knox.models import AuthToken + +from base64 import b64encode + + +class IssueApiTest(TestCase): + + def setUp(self): + super().setUp() + 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='evt') + 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_issues_empty(self): + response = self.client.get('/api/2/tickets/') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_issues(self): + now = datetime.now() + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + mail1 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=issue, + timestamp=now, + ) + mail2 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='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", + timestamp=now + timedelta(seconds=4), + ) + match = ItemRelation.objects.create( + issue_thread=issue, + item=self.item, + timestamp=now + timedelta(seconds=5), + ) + self.assertEqual('pending_new', issue.state) + self.assertEqual('test issue', issue.name) + self.assertEqual(self.user, issue.assigned_to) + self.assertEqual(36, len(issue.uuid)) + response = self.client.get('/api/2/tickets/') + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]['id'], issue.id) + self.assertEqual(response.json()[0]['name'], "test issue") + self.assertEqual(response.json()[0]['state'], "pending_new") + self.assertEqual(response.json()[0]['event'], "evt") + self.assertEqual(response.json()[0]['assigned_to'], self.user.username) + self.assertEqual(response.json()[0]['uuid'], issue.uuid) + self.assertEqual(response.json()[0]['last_activity'], match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(len(response.json()[0]['timeline']), 6) + self.assertEqual(response.json()[0]['timeline'][0]['type'], 'state') + self.assertEqual(response.json()[0]['timeline'][1]['type'], 'mail') + self.assertEqual(response.json()[0]['timeline'][2]['type'], 'mail') + self.assertEqual(response.json()[0]['timeline'][3]['type'], 'assignment') + self.assertEqual(response.json()[0]['timeline'][4]['type'], 'comment') + self.assertEqual(response.json()[0]['timeline'][5]['type'], 'item_relation') + self.assertEqual(response.json()[0]['timeline'][1]['id'], mail1.id) + self.assertEqual(response.json()[0]['timeline'][2]['id'], mail2.id) + self.assertEqual(response.json()[0]['timeline'][3]['id'], comment.id) + self.assertEqual(response.json()[0]['timeline'][0]['state'], 'pending_new') + self.assertEqual(response.json()[0]['timeline'][1]['sender'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['recipient'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['subject'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['body'], 'test') + self.assertEqual(response.json()[0]['timeline'][1]['timestamp'], + mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(response.json()[0]['timeline'][2]['sender'], 'test') + self.assertEqual(response.json()[0]['timeline'][2]['recipient'], 'test') + self.assertEqual(response.json()[0]['timeline'][2]['subject'], 'test') + self.assertEqual(response.json()[0]['timeline'][2]['body'], 'test') + self.assertEqual(response.json()[0]['timeline'][2]['timestamp'], + mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(response.json()[0]['timeline'][3]['assigned_to'], self.user.username) + self.assertEqual(response.json()[0]['timeline'][3]['timestamp'], + assignment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(response.json()[0]['timeline'][4]['comment'], 'test') + self.assertEqual(response.json()[0]['timeline'][4]['timestamp'], + comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(response.json()[0]['timeline'][5]['status'], 'possible') + self.assertEqual(response.json()[0]['timeline'][5]['timestamp'], + match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) + self.assertEqual(response.json()[0]['timeline'][5]['item']['description'], "foo") + self.assertEqual(response.json()[0]['timeline'][5]['item']['event'], "evt") + self.assertEqual(response.json()[0]['timeline'][5]['item']['box'], "box1") + self.assertEqual(response.json()[0]['related_items'][0]['description'], "foo") + self.assertEqual(response.json()[0]['related_items'][0]['event'], "evt") + self.assertEqual(response.json()[0]['related_items'][0]['box'], "box1") + + def test_issues_incomplete_timeline(self): + now = datetime.now() + issue1 = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + issue2 = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + issue3 = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + mail1 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=issue2, + timestamp=now + timedelta(seconds=2), + ) + comment = Comment.objects.create( + issue_thread=issue3, + comment="test", + timestamp=now + timedelta(seconds=3), + ) + + response = self.client.get('/api/2/tickets/') + self.assertEqual(200, response.status_code) + self.assertEqual(3, len(response.json())) + self.assertEqual(issue1.id, response.json()[0]['id']) + self.assertEqual("evt", response.json()[0]['event']) + self.assertEqual(issue2.id, response.json()[1]['id']) + self.assertEqual("evt", response.json()[1]['event']) + self.assertEqual(issue3.id, response.json()[2]['id']) + self.assertEqual("evt", response.json()[2]['event']) + self.assertEqual(issue1.state_changes.first().timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['last_activity']) + self.assertEqual(mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[1]['last_activity']) + self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[2]['last_activity']) + self.assertEqual(1, len(response.json()[0]['timeline'])) + self.assertEqual(2, len(response.json()[1]['timeline'])) + self.assertEqual(2, len(response.json()[2]['timeline'])) + self.assertEqual('state', response.json()[0]['timeline'][0]['type']) + self.assertEqual('state', response.json()[1]['timeline'][0]['type']) + self.assertEqual('mail', response.json()[1]['timeline'][1]['type']) + self.assertEqual('state', response.json()[2]['timeline'][0]['type']) + self.assertEqual('comment', response.json()[2]['timeline'][1]['type']) + self.assertEqual('pending_new', response.json()[0]['timeline'][0]['state']) + self.assertEqual('pending_new', response.json()[1]['timeline'][0]['state']) + self.assertEqual('test', response.json()[1]['timeline'][1]['sender']) + self.assertEqual('test', response.json()[1]['timeline'][1]['recipient']) + self.assertEqual('test', response.json()[1]['timeline'][1]['subject']) + self.assertEqual('test', response.json()[1]['timeline'][1]['body']) + self.assertEqual(mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[1]['timeline'][1]['timestamp']) + self.assertEqual('pending_new', response.json()[2]['timeline'][0]['state']) + self.assertEqual('test', response.json()[2]['timeline'][1]['comment']) + self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[2]['timeline'][1]['timestamp']) + + def test_issues_with_files(self): + from django.core.files.base import ContentFile + from hashlib import sha256 + now = datetime.now() + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + mail1 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=issue, + timestamp=now, + ) + mail2 = Email.objects.create( + subject='test', + body='test', + sender='test', + recipient='test', + issue_thread=issue, + in_reply_to=mail1.reference, + timestamp=now + timedelta(seconds=2), + ) + comment = Comment.objects.create( + issue_thread=issue, + comment="test", + timestamp=now + timedelta(seconds=3), + ) + file1 = EmailAttachment.objects.create( + name='file1', mime_type='text/plain', file=ContentFile(b"foo1", "f1"), + hash=sha256(b"foo1").hexdigest(), email=mail1 + ) + file2 = EmailAttachment.objects.create( + name='file2', mime_type='text/plain', file=ContentFile(b"foo2", "f2"), + hash=sha256(b"foo2").hexdigest(), email=mail1 + ) + + response = self.client.get('/api/2/tickets/') + self.assertEqual(200, response.status_code) + self.assertEqual(1, len(response.json())) + self.assertEqual(issue.id, response.json()[0]['id']) + self.assertEqual("evt", response.json()[0]['event']) + self.assertEqual('pending_new', response.json()[0]['state']) + self.assertEqual('test issue', response.json()[0]['name']) + self.assertEqual(None, response.json()[0]['assigned_to']) + self.assertEqual(36, len(response.json()[0]['uuid'])) + self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['last_activity']) + self.assertEqual(4, len(response.json()[0]['timeline'])) + self.assertEqual('state', response.json()[0]['timeline'][0]['type']) + self.assertEqual('mail', response.json()[0]['timeline'][1]['type']) + self.assertEqual('mail', response.json()[0]['timeline'][2]['type']) + self.assertEqual('comment', response.json()[0]['timeline'][3]['type']) + self.assertEqual(mail1.id, response.json()[0]['timeline'][1]['id']) + self.assertEqual(mail2.id, response.json()[0]['timeline'][2]['id']) + self.assertEqual(comment.id, response.json()[0]['timeline'][3]['id']) + self.assertEqual('pending_new', response.json()[0]['timeline'][0]['state']) + self.assertEqual('test', response.json()[0]['timeline'][1]['sender']) + self.assertEqual('test', response.json()[0]['timeline'][1]['recipient']) + self.assertEqual('test', response.json()[0]['timeline'][1]['subject']) + self.assertEqual('test', response.json()[0]['timeline'][1]['body']) + self.assertEqual(mail1.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['timeline'][1]['timestamp']) + self.assertEqual('test', response.json()[0]['timeline'][2]['sender']) + self.assertEqual('test', response.json()[0]['timeline'][2]['recipient']) + + self.assertEqual('test', response.json()[0]['timeline'][2]['subject']) + self.assertEqual('test', response.json()[0]['timeline'][2]['body']) + self.assertEqual(mail2.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['timeline'][2]['timestamp']) + self.assertEqual('test', response.json()[0]['timeline'][3]['comment']) + self.assertEqual(comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + response.json()[0]['timeline'][3]['timestamp']) + self.assertEqual(2, len(response.json()[0]['timeline'][1]['attachments'])) + self.assertEqual(0, len(response.json()[0]['timeline'][2]['attachments'])) + self.assertEqual('file1', response.json()[0]['timeline'][1]['attachments'][0]['name']) + self.assertEqual('file2', response.json()[0]['timeline'][1]['attachments'][1]['name']) + self.assertEqual('text/plain', response.json()[0]['timeline'][1]['attachments'][0]['mime_type']) + self.assertEqual('text/plain', response.json()[0]['timeline'][1]['attachments'][1]['mime_type']) + self.assertEqual(file1.hash, response.json()[0]['timeline'][1]['attachments'][0]['hash']) + self.assertEqual(file2.hash, response.json()[0]['timeline'][1]['attachments'][1]['hash']) + + def test_manual_creation(self): + response = self.client.post('/api/2/evt/tickets/manual/', + {'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['state'], 'pending_new') + self.assertEqual(response.json()['name'], 'test issue') + self.assertEqual(response.json()['assigned_to'], None) + self.assertEqual("evt", response.json()['event']) + timeline = response.json()['timeline'] + self.assertEqual(len(timeline), 2) + self.assertEqual(timeline[0]['type'], 'state') + self.assertEqual(timeline[0]['state'], 'pending_new') + self.assertEqual(timeline[1]['type'], 'mail') + self.assertEqual(timeline[1]['sender'], 'test') + self.assertEqual(timeline[1]['recipient'], 'test') + self.assertEqual(timeline[1]['subject'], 'test issue') + self.assertEqual(timeline[1]['body'], 'test') + + def test_manual_creation_none(self): + response = self.client.post('/api/2/none/tickets/manual/', + {'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'}, + content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['state'], 'pending_new') + self.assertEqual(response.json()['name'], 'test issue') + self.assertEqual(response.json()['assigned_to'], None) + self.assertEqual(None, response.json()['event']) + timeline = response.json()['timeline'] + self.assertEqual(len(timeline), 2) + self.assertEqual(timeline[0]['type'], 'state') + self.assertEqual(timeline[0]['state'], 'pending_new') + self.assertEqual(timeline[1]['type'], 'mail') + self.assertEqual(timeline[1]['sender'], 'test') + self.assertEqual(timeline[1]['recipient'], 'test') + self.assertEqual(timeline[1]['subject'], 'test issue') + self.assertEqual(timeline[1]['body'], 'test') + + def test_manual_creation_invalid(self): + response = self.client.post('/api/2/foobar/tickets/manual/', + {'name': 'test issue', 'sender': 'test', 'recipient': 'test', 'body': 'test'}, + content_type='application/json') + self.assertEqual(response.status_code, 400) + + def test_post_comment_altenative(self): + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': 'test'}) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()['comment'], 'test') + self.assertEqual(response.json()['issue_thread'], issue.id) + self.assertEqual(response.json()['timestamp'], response.json()['timestamp']) + + def test_post_alt_comment_empty(self): + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + response = self.client.post(f'/api/2/tickets/{issue.id}/comment/', {'comment': ''}) + self.assertEqual(response.status_code, 400) + + def test_state_change(self): + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'pending_open'}, + content_type='application/json') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['state'], 'pending_open') + self.assertEqual(response.json()['name'], 'test issue') + self.assertEqual(response.json()['assigned_to'], None) + timeline = response.json()['timeline'] + self.assertEqual(len(timeline), 2) + self.assertEqual(timeline[0]['type'], 'state') + self.assertEqual(timeline[0]['state'], 'pending_new') + self.assertEqual(timeline[1]['type'], 'state') + self.assertEqual(timeline[1]['state'], 'pending_open') + + def test_state_change_invalid_state(self): + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'state': 'invalid'}, + content_type='application/json') + self.assertEqual(400, response.status_code) + + def test_assign_user(self): + issue = IssueThread.objects.create( + name="test issue", + event=self.event, + ) + response = self.client.patch(f'/api/2/tickets/{issue.id}/', {'assigned_to': self.user.username}, + content_type='application/json') + self.assertEqual(200, response.status_code) + self.assertEqual('pending_new', response.json()['state']) + self.assertEqual('test issue', response.json()['name']) + self.assertEqual("evt", response.json()['event']) + self.assertEqual(self.user.username, response.json()['assigned_to']) + timeline = response.json()['timeline'] + self.assertEqual(2, len(timeline)) + self.assertEqual('state', timeline[0]['type']) + self.assertEqual('pending_new', timeline[0]['state']) + self.assertEqual('assignment', timeline[1]['type']) + self.assertEqual(self.user.username, timeline[1]['assigned_to']) + + +class IssueSearchTest(TestCase): + + def setUp(self): + super().setUp() + 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): + 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']) diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php deleted file mode 100644 index bf9496b..0000000 --- a/database/factories/ModelFactory.php +++ /dev/null @@ -1,19 +0,0 @@ -define(App\User::class, function (Faker\Generator $faker) { - return [ - 'name' => $faker->name, - 'email' => $faker->email, - ]; -}); diff --git a/database/migrations/2019_11_14_213133_create_events_table.php b/database/migrations/2019_11_14_213133_create_events_table.php deleted file mode 100644 index 7c0d7cc..0000000 --- a/database/migrations/2019_11_14_213133_create_events_table.php +++ /dev/null @@ -1,41 +0,0 @@ -charset = 'utf8'; - $table->collation = 'utf8_unicode_ci'; - $table->bigIncrements('eid'); - $table->string('name'); - $table->string('slug'); - $table->timestamps(); - $table->timestamp('start')->nullable(); - $table->timestamp('end')->nullable(); - $table->timestamp('pre_start')->nullable(); - $table->timestamp('post_end')->nullable(); - - $table->unique('slug','uslug'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('events'); - } -} diff --git a/database/migrations/2019_11_14_214212_create_containers_table.php b/database/migrations/2019_11_14_214212_create_containers_table.php deleted file mode 100644 index 2ee7e5a..0000000 --- a/database/migrations/2019_11_14_214212_create_containers_table.php +++ /dev/null @@ -1,32 +0,0 @@ -bigIncrements('cid'); - $table->string('name'); - $table->timestamps(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('container'); - } -} diff --git a/database/migrations/2019_11_14_232911_create_items_table.php b/database/migrations/2019_11_14_232911_create_items_table.php deleted file mode 100644 index 164ecd1..0000000 --- a/database/migrations/2019_11_14_232911_create_items_table.php +++ /dev/null @@ -1,43 +0,0 @@ -bigIncrements('iid'); - $table->unsignedBigInteger('item_uid'); - $table->string('bezeichnung'); - $table->timestamp('wann'); - $table->string('wo'); - $table->unsignedBigInteger('eid'); - $table->unsignedBigInteger('cid'); - $table->timestamps(); - $table->softDeletes(); - - $table->unique(['eid', 'item_uid']); - - $table->foreign('eid')->references('eid')->on('events'); - $table->foreign('cid')->references('cid')->on('containers'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('items'); - } -} diff --git a/database/migrations/2019_11_28_035156_create_files_table.php b/database/migrations/2019_11_28_035156_create_files_table.php deleted file mode 100644 index e5da094..0000000 --- a/database/migrations/2019_11_28_035156_create_files_table.php +++ /dev/null @@ -1,37 +0,0 @@ -bigIncrements('id'); - $table->timestamps(); - - $table->unsignedBigInteger('iid'); - $table->string('hash'); - - - $table->foreign('iid')->references('iid')->on('items'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::dropIfExists('file'); - } -} diff --git a/database/migrations/2019_11_29_005038_container_add_soft_deletes.php b/database/migrations/2019_11_29_005038_container_add_soft_deletes.php deleted file mode 100644 index 45bb207..0000000 --- a/database/migrations/2019_11_29_005038_container_add_soft_deletes.php +++ /dev/null @@ -1,30 +0,0 @@ -softDeletes(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - // - } -} diff --git a/database/migrations/2019_12_15_203050_rename__itemfields.php b/database/migrations/2019_12_15_203050_rename__itemfields.php deleted file mode 100644 index 202cab0..0000000 --- a/database/migrations/2019_12_15_203050_rename__itemfields.php +++ /dev/null @@ -1,34 +0,0 @@ -renameColumn('bezeichnung','description'); - }); - - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('items', function (Blueprint $table) { - $table->renameColumn('description', 'bezeichnung'); - }); - } -} diff --git a/database/migrations/2019_12_24_134319_delete_id_on_file.php b/database/migrations/2019_12_24_134319_delete_id_on_file.php deleted file mode 100644 index 1e87a6a..0000000 --- a/database/migrations/2019_12_24_134319_delete_id_on_file.php +++ /dev/null @@ -1,31 +0,0 @@ -dropColumn('id'); - $table->primary('hash'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - // - } -} diff --git a/database/migrations/2019_12_27_193619_add_item_returned_at_member.php b/database/migrations/2019_12_27_193619_add_item_returned_at_member.php deleted file mode 100644 index aa4fb03..0000000 --- a/database/migrations/2019_12_27_193619_add_item_returned_at_member.php +++ /dev/null @@ -1,33 +0,0 @@ -timestamp('returned_at')->nullable(); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('items', function (Blueprint $table) { - $table->dropColumn('returned_at'); - }); - } -} diff --git a/database/migrations/2020_01_18_001554_add_currentfiles_view.php b/database/migrations/2020_01_18_001554_add_currentfiles_view.php deleted file mode 100644 index 7220cb3..0000000 --- a/database/migrations/2020_01_18_001554_add_currentfiles_view.php +++ /dev/null @@ -1,36 +0,0 @@ -dropColumn('wo'); - }); - Schema::table('items', function (Blueprint $table) { - $table->dropColumn('wann'); - }); - Schema::table('items', function (Blueprint $table) { - $table->renameColumn('item_uid','uid'); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - // - } -} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php deleted file mode 100644 index 23526c9..0000000 --- a/database/seeds/DatabaseSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ -call('UsersTableSeeder'); - } -} diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..611281b --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1 @@ +inventory.yml \ No newline at end of file diff --git a/deploy/ansible/inventory.yml.sample b/deploy/ansible/inventory.yml.sample new file mode 100644 index 0000000..2a50efd --- /dev/null +++ b/deploy/ansible/inventory.yml.sample @@ -0,0 +1,16 @@ +--- +c3lf-nodes: + hosts: + : + ansible_ssh_host: + ansible_ssh_user: root + web_domain: + git_branch: master + git_repo: + db_password: + mail_domain: + main_email: + legacy_api_user: + legacy_api_password: + debug_mode_active: false + django_secret_key: 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv' \ No newline at end of file diff --git a/deploy/ansible/playbooks/deploy-c3lf-sys3.yml b/deploy/ansible/playbooks/deploy-c3lf-sys3.yml new file mode 100644 index 0000000..4005146 --- /dev/null +++ b/deploy/ansible/playbooks/deploy-c3lf-sys3.yml @@ -0,0 +1,370 @@ +- name: 'deploy c3lf-sys3' + hosts: 'c3lf-nodes' + handlers: + - name: restart nginx + service: + name: nginx + state: restarted + + - name: restart postfix + service: + name: postfix + state: restarted + + - name: restart rspamd + service: + name: rspamd + state: restarted + + - name: restart mariadb + service: + name: mariadb + state: restarted + + - name: restart c3lf-sys3 + service: + name: c3lf-sys3 + state: restarted + + tasks: + - name: Update apt-get repo and cache + apt: update_cache=yes force_apt_get=yes cache_valid_time=3600 + + - name: Upgrade all apt packages + apt: upgrade=dist force_apt_get=yes + + - name: Ansible apt-get to install base tools + apt: + name: + - htop + - tcpdump + - jq + - curl + - libsensors5 + - prometheus-node-exporter + - openssh-server + state: present + force_apt_get: yes + + - name: Remove useless packages from the cache + apt: + autoclean: yes + + - name: Remove dependencies that are no longer required + apt: + autoremove: yes + + - name: Check if a reboot is needed for debian + register: reboot_required_file + stat: path=/var/run/reboot-required get_checksum=no + + - name: Reboot the Debian or Ubuntu server + reboot: + msg: "Reboot initiated by Ansible due to kernel updates" + connect_timeout: 5 + reboot_timeout: 300 + pre_reboot_delay: 0 + post_reboot_delay: 30 + test_command: uptime + when: reboot_required_file.stat.exists + + - name: Ansible apt-get to install sys3 requirements + apt: + name: + - ufw + - fail2ban + - nginx + - redis + - python3 + - python3-pip + - python3-venv + - python3-passlib + - certbot + - python3-certbot-nginx + - mariadb-server + - python3-dev + - python3-mysqldb + - default-libmysqlclient-dev + - build-essential + - postfix + - rspamd + - git + - pkg-config + - npm + state: present + + - name: remove default nginx site + file: + path: /etc/nginx/sites-enabled/default + state: absent + + - name: remove default nginx site + file: + path: /etc/nginx/sites-available/default + state: absent + + - name: UFW allow SSH + ufw: + rule: allow + port: 22 + proto: tcp + state: enabled + + - name: UFW logging off + ufw: + logging: off + + - name: Configure nginx + template: + src: templates/nginx.conf.j2 + dest: /etc/nginx/sites-available/c3lf-sys3.conf + notify: + - restart nginx + + - name: UFW allow http + ufw: + rule: allow + port: 80 + proto: tcp + state: enabled + + - name: UFW allow https + ufw: + rule: allow + port: 443 + proto: tcp + state: enabled + + - name: Check if initial certbot certificate is needed + stat: + path: /etc/letsencrypt/live/{{web_domain}}/fullchain.pem + register: certbot_cert_exists + + - name: Check nginx ssl config + stat: + path: /etc/letsencrypt/options-ssl-nginx.conf + register: nginx_ssl_config_exists + + - block: + - name: stop nginx + service: + name: nginx + state: stopped + - name: disable c3lf-sys3 site + file: + path: /etc/nginx/sites-enabled/c3lf-sys3.conf + state: absent + - name: add certbot domain + command: "certbot certonly --standalone -d {{web_domain}} --non-interactive --agree-tos --email {{main_email}}" + - name: install letsencrypt ssl config + command: "certbot install --nginx --non-interactive" + - name: enable c3lf-sys3 site + file: + src: /etc/nginx/sites-available/c3lf-sys3.conf + dest: /etc/nginx/sites-enabled/c3lf-sys3.conf + state: link + - name: start nginx + service: + name: nginx + state: started + when: certbot_cert_exists.stat.exists == false or nginx_ssl_config_exists.stat.exists == false + + - name: Enable certbot auto renew + cron: + name: "certbot-auto renew" + minute: "0" + hour: "12" + job: "certbot renew --quiet --no-self-upgrade --nginx --cert-name {{web_domain}}" + state: present + + - name: Configure basic auth + htpasswd: + path: /etc/nginx/conf.d/lf-prod.htpasswd + name: "{{ legacy_api_user }}" + password: "{{ legacy_api_password }}" + state: present + notify: + - restart nginx + + - name: Enable nginx site + file: + src: /etc/nginx/sites-available/c3lf-sys3.conf + dest: /etc/nginx/sites-enabled/c3lf-sys3.conf + state: link + notify: + - restart nginx + + - name: Initially start nginx + service: + name: nginx + state: started + enabled: yes + + - name: create database + mysql_db: + name: c3lf_sys3 + state: present + login_unix_socket: /var/run/mysqld/mysqld.sock + + - name: create database user + mysql_user: + name: c3lf_sys3 + password: "{{ db_password }}" + priv: "c3lf_sys3.*:ALL" + state: present + login_unix_socket: /var/run/mysqld/mysqld.sock + + - name: configure webdir + file: + path: /var/www + state: directory + owner: www-data + group: www-data + mode: 0755 + + - name: configure webdir + file: + path: /var/www/c3lf-sys3 + state: directory + owner: www-data + group: www-data + mode: 0755 + + - name: install python app + become: true + become_user: www-data + become_method: su + become_flags: '-s /bin/bash' + block: + - name: create repo dir + git: + repo: "{{ git_repo }}" + dest: /var/www/c3lf-sys3/repo + version: "{{ git_branch }}" + force: yes + recursive: yes + single_branch: yes + register: git_repo + notify: + - restart c3lf-sys3 + + - name: check if venv exists + stat: + path: /var/www/c3lf-sys3/venv/bin/python3 + register: venv_exists + + - name: create venv + command: "python3 -m venv /var/www/c3lf-sys3/venv" + when: venv_exists.stat.exists == false + + - name: install requirements + pip: + requirements: /var/www/c3lf-sys3/repo/core/requirements.prod.txt + virtualenv: /var/www/c3lf-sys3/venv + state: present + when: git_repo.changed == true + notify: + - restart c3lf-sys3 + + - name: configure django + template: + src: templates/django.env.j2 + dest: /var/www/c3lf-sys3/repo/core/.env + + - name: migrate database + shell: "/var/www/c3lf-sys3/venv/bin/python /var/www/c3lf-sys3/repo/core/manage.py migrate" + when: git_repo.changed == true + + - name: create superuser + shell: "/var/www/c3lf-sys3/venv/bin/python /var/www/c3lf-sys3/repo/core/manage.py createsuperuser --noinput || true" + when: git_repo.changed == true + environment: + DJANGO_SUPERUSER_USERNAME: admin + DJANGO_SUPERUSER_PASSWORD: "{{ django_password }}" + DJANGO_SUPERUSER_EMAIL: "{{ main_email }}" + + - name: collect static files + shell: "/var/www/c3lf-sys3/venv/bin/python /var/www/c3lf-sys3/repo/core/manage.py collectstatic --noinput" + when: git_repo.changed == true + + - name: js config + template: + src: templates/config.js.j2 + dest: /var/www/c3lf-sys3/repo/web/src/config.js + + - name: install build dependencies + command: + cmd: "npm install" + chdir: /var/www/c3lf-sys3/repo/web + when: git_repo.changed == true + + - name: build frontend + command: + cmd: "npm run build" + chdir: /var/www/c3lf-sys3/repo/web + when: git_repo.changed == true + + - name: add c3lf-sys3 service + template: + src: templates/c3lf-sys3.service.j2 + dest: /etc/systemd/system/c3lf-sys3.service + notify: + - restart c3lf-sys3 + + - name: reload systemd + systemd: + daemon_reload: yes + + - name: start c3lf-sys3 service + service: + name: c3lf-sys3 + state: started + enabled: yes + + - name: add postfix to www-data group + user: + name: postfix + groups: www-data + append: yes + notify: + - restart postfix + + - name: add custom transport config + lineinfile: + path: /etc/postfix/master.cf + line: "c3lf-sys3 unix - n n - - lmtp" + state: present + create: yes + notify: + - restart postfix + + - name: configure postfix + template: + src: templates/postfix.cf.j2 + dest: /etc/postfix/main.cf + 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: | + write_servers = "localhost"; + read_servers = "localhost"; + dest: /etc/rspamd/local.d/redis.conf + notify: + - restart rspamd + + + - name: UFW allow smtp + ufw: + rule: allow + port: 25 + proto: tcp + state: enabled \ No newline at end of file diff --git a/deploy/ansible/playbooks/templates/c3lf-sys3.service.j2 b/deploy/ansible/playbooks/templates/c3lf-sys3.service.j2 new file mode 100644 index 0000000..515a82d --- /dev/null +++ b/deploy/ansible/playbooks/templates/c3lf-sys3.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=standalone c3lf-sys3 server +After=network.target + +[Service] +Type=notify +WorkingDirectory=/var/www/c3lf-sys3 +ExecStart=/var/www/c3lf-sys3/venv/bin/python3 /var/www/c3lf-sys3/repo/core/server.py +Environment=DJANGO_SETTINGS_MODULE=core.settings +Restart=always +RestartSec=5 +User=www-data +Group=www-data +StandardOutput=append:/var/www/c3lf-sys3/service.info.log +StandardError=append:/var/www/c3lf-sys3/service.error.log + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/deploy/ansible/playbooks/templates/config.js.j2 b/deploy/ansible/playbooks/templates/config.js.j2 new file mode 100644 index 0000000..b95ac7e --- /dev/null +++ b/deploy/ansible/playbooks/templates/config.js.j2 @@ -0,0 +1,9 @@ +export default { + service: { + url: 'https://{{ web_domain }}/api', + auth: { + username: '{{ legacy_api_user }}', + password: '{{ legacy_api_password }}' + } + } +}; \ No newline at end of file diff --git a/deploy/ansible/playbooks/templates/django.env.j2 b/deploy/ansible/playbooks/templates/django.env.j2 new file mode 100644 index 0000000..c9b1c83 --- /dev/null +++ b/deploy/ansible/playbooks/templates/django.env.j2 @@ -0,0 +1,15 @@ +REDIS_HOST=localhost +DB_HOST=localhost +DB_PORT=3306 +DB_NAME=c3lf_sys3 +DB_USER=c3lf_sys3 +DB_PASSWORD={{ db_password }} +HTTP_HOST={{ web_domain }} +MAIL_DOMAIN={{ mail_domain }} +LEGACY_API_USER={{ legacy_api_user }} +LEGACY_API_PASSWORD={{ legacy_api_password }} +MEDIA_ROOT=/var/www/c3lf-sys3/userfiles +STATIC_ROOT=/var/www/c3lf-sys3/staticfiles +ACTIVE_SPAM_TRAINING=True +DEBUG_MODE_ACTIVE={{ debug_mode_active }} +DJANGO_SECRET_KEY={{ django_secret_key }} diff --git a/deploy/ansible/playbooks/templates/nginx.conf.j2 b/deploy/ansible/playbooks/templates/nginx.conf.j2 new file mode 100644 index 0000000..1bde29a --- /dev/null +++ b/deploy/ansible/playbooks/templates/nginx.conf.j2 @@ -0,0 +1,105 @@ +upstream c3lf-sys3 { + server unix:/var/www/c3lf-sys3/web.sock; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + + server_name {{ web_domain }}; + client_max_body_size 1024M; + + location / { + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE, PUT'; + add_header 'Content-Security-Policy' "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: wss:;"; + root /var/www/c3lf-sys3/repo/web/dist; + index index.html index.htm index.nginx-debian.html; + try_files $uri $uri/ /index.html; + } + + location /ws { + proxy_http_version 1.1; + #auth_basic off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_pass http://c3lf-sys3; + } + + location ~ ^/(api|media)/ { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_redirect off; + proxy_buffering off; + proxy_pass http://c3lf-sys3; + + location ~ ^/api/1 { + auth_basic C3LF; + auth_basic_user_file conf.d/lf-prod.htpasswd; + } + } + + location /djangoadmin { + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_redirect off; + proxy_buffering off; + proxy_pass http://c3lf-sys3; + } + + location /redirect_media/ { + internal; + alias /var/www/c3lf-sys3/userfiles/; + } + + location /redirect_thumbnail/ { + internal; + alias /var/www/c3lf-sys3/userfiles/thumbnails/; + } + + location /static/ { + alias /var/www/c3lf-sys3/staticfiles/; + } + + location /metrics { + allow 95.156.226.90; + allow 127.0.0.1; + allow ::1; + deny all; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_redirect off; + proxy_buffering off; + proxy_pass http://c3lf-sys3; + } + + listen 443 ssl http2; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/{{ web_domain }}/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/{{ web_domain }}/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + +} + +server { + if ($host = {{ web_domain }}) { + return 301 https://$host$request_uri; + } # managed by Certbot + + server_name {{ web_domain }}; + + listen 80; + return 404; # managed by Certbot +} diff --git a/deploy/ansible/playbooks/templates/postfix.cf.j2 b/deploy/ansible/playbooks/templates/postfix.cf.j2 new file mode 100644 index 0000000..f6e0b09 --- /dev/null +++ b/deploy/ansible/playbooks/templates/postfix.cf.j2 @@ -0,0 +1,52 @@ +# See /usr/share/postfix/main.cf.dist for a commented, more complete version + + +# Debian specific: Specifying a file name will cause the first +# line of that file to be used as the name. The Debian default +# is /etc/mailname. +#myorigin = /etc/mailname + +smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU) +biff = no + +# appending .domain is the MUA's job. +append_dot_mydomain = no + +readme_directory = no + +# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 3.6 on +# fresh installs. +compatibility_level = 3.6 + +# TLS parameters +smtp_use_tls = yes +smtp_force_tls = yes +smtpd_use_tls = yes +smtpd_tls_cert_file=/etc/letsencrypt/live/{{ web_domain }}/fullchain.pem +smtpd_tls_key_file=/etc/letsencrypt/live/{{ web_domain }}/privkey.pem +smtpd_tls_security_level=may + +smtp_tls_CApath=/etc/ssl/certs +smtp_tls_security_level=may +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 +alias_maps = hash:/etc/aliases +alias_database = hash:/etc/aliases +myorigin = /etc/mailname +mydestination = $myhostname, , localhost +mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 +mailbox_size_limit = 0 +recipient_delimiter = + +inet_interfaces = all +inet_protocols = all + +maillog_file = /var/log/mail.log + +virtual_mailbox_domains = {{ mail_domain }} +virtual_transport=c3lf-sys3:unix:/var/www/c3lf-sys3/lmtp.sock + +smtpd_milters = inet:localhost:11332 +milter_default_action = accept \ No newline at end of file diff --git a/deploy/ansible/playbooks/templates/rspamd-dkim.cf.j2 b/deploy/ansible/playbooks/templates/rspamd-dkim.cf.j2 new file mode 100644 index 0000000..9e21aa5 --- /dev/null +++ b/deploy/ansible/playbooks/templates/rspamd-dkim.cf.j2 @@ -0,0 +1,79 @@ +# 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 }}"; + } +} + diff --git a/deploy/dev/Dockerfile.backend b/deploy/dev/Dockerfile.backend new file mode 100644 index 0000000..57ab856 --- /dev/null +++ b/deploy/dev/Dockerfile.backend @@ -0,0 +1,8 @@ +FROM python:3.11-slim-bookworm +LABEL authors="lagertonne" + +ENV PYTHONUNBUFFERED 1 +RUN mkdir /code +WORKDIR /code +COPY requirements.dev.txt /code/ +RUN pip install -r requirements.dev.txt \ No newline at end of file diff --git a/deploy/dev/Dockerfile.frontend b/deploy/dev/Dockerfile.frontend new file mode 100644 index 0000000..a8fd652 --- /dev/null +++ b/deploy/dev/Dockerfile.frontend @@ -0,0 +1,6 @@ +FROM node:22-alpine + +RUN mkdir /web +WORKDIR /web +COPY package.json /web/ +RUN npm install diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml new file mode 100644 index 0000000..cf1bfdc --- /dev/null +++ b/deploy/dev/docker-compose.yml @@ -0,0 +1,33 @@ +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' + environment: + - HTTP_HOST=core + - DB_FILE=.local/dev.db + - DEBUG_MODE_ACTIVE=true + volumes: + - ../../core:/code:ro + - ../testdata.py:/code/testdata.py:ro + - backend_context:/code/.local + ports: + - "8000:8000" + + frontend: + build: + context: ../../web + dockerfile: ../deploy/dev/Dockerfile.frontend + command: npm run serve + volumes: + - ../../web/src:/web/src + - ./vue.config.js:/web/vue.config.js + ports: + - "8080:8080" + depends_on: + - core + +volumes: + backend_context: \ No newline at end of file diff --git a/deploy/dev/vue.config.js b/deploy/dev/vue.config.js new file mode 100644 index 0000000..f8f3c26 --- /dev/null +++ b/deploy/dev/vue.config.js @@ -0,0 +1,27 @@ +// vue.config.js + +module.exports = { + devServer: { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "*" + }, + proxy: { + '^/media/2': { + target: 'http://core:8000/', + }, + '^/api/2': { + target: 'http://core:8000/', + }, + '^/api/1': { + target: 'http://core:8000/', + }, + '^/ws/2': { + target: 'http://core:8000/', + ws: true, + logLevel: 'debug', + }, + } + } +} \ No newline at end of file diff --git a/deploy/testdata.py b/deploy/testdata.py new file mode 100644 index 0000000..dca385f --- /dev/null +++ b/deploy/testdata.py @@ -0,0 +1,88 @@ +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() diff --git a/deploy/testing/Dockerfile.backend b/deploy/testing/Dockerfile.backend new file mode 100644 index 0000000..06e494f --- /dev/null +++ b/deploy/testing/Dockerfile.backend @@ -0,0 +1,11 @@ +FROM python:3.11-slim-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 pip install -r requirements.prod.txt +COPY .. /code/ \ No newline at end of file diff --git a/deploy/testing/Dockerfile.frontend b/deploy/testing/Dockerfile.frontend new file mode 100644 index 0000000..a8fd652 --- /dev/null +++ b/deploy/testing/Dockerfile.frontend @@ -0,0 +1,6 @@ +FROM node:22-alpine + +RUN mkdir /web +WORKDIR /web +COPY package.json /web/ +RUN npm install diff --git a/deploy/testing/docker-compose.yml b/deploy/testing/docker-compose.yml new file mode 100644 index 0000000..4a82289 --- /dev/null +++ b/deploy/testing/docker-compose.yml @@ -0,0 +1,76 @@ +name: c3lf-sys3-testing +services: + redis: + image: redis + ports: + - "6379:6379" + + 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" + + core: + build: + context: ../../core + dockerfile: ../deploy/testing/Dockerfile.backend + command: bash -c 'python manage.py migrate && python testdata.py && python /code/server.py' + environment: + - HTTP_HOST=core + - REDIS_HOST=redis + - DB_HOST=db + - DB_PORT=3306 + - 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 + ports: + - "8000:8000" + depends_on: + - db + - redis + - mail + + frontend: + build: + context: ../../web + dockerfile: ../deploy/testing/Dockerfile.frontend + command: npm run serve + volumes: + - ../../web:/web:ro + - ./vue.config.js:/web/vue.config.js:ro + - frontend_context:/web + 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: diff --git a/deploy/testing/vue.config.js b/deploy/testing/vue.config.js new file mode 100644 index 0000000..f8f3c26 --- /dev/null +++ b/deploy/testing/vue.config.js @@ -0,0 +1,27 @@ +// vue.config.js + +module.exports = { + devServer: { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "*" + }, + proxy: { + '^/media/2': { + target: 'http://core:8000/', + }, + '^/api/2': { + target: 'http://core:8000/', + }, + '^/api/1': { + target: 'http://core:8000/', + }, + '^/ws/2': { + target: 'http://core:8000/', + ws: true, + logLevel: 'debug', + }, + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 567999f..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -version: '3.6' -services: - webserver: - image: webdevops/php-nginx-dev - ports: - - "80:80" - volumes: - - ./:/app - - ./container/web/init.sh:/entrypoint.d/09-init.sh - - ./container/web/location-root.conf:/opt/docker/etc/nginx/vhost.common.d/10-location-root.conf - - ./container/web/general.conf:/opt/docker/etc/nginx/vhost.common.d/10-general.conf - - ./container/web/images.conf:/opt/docker/etc/nginx/vhost.common.d/20-images.conf - environment: - WEB_DOCUMENT_ROOT: /app/public - XDEBUG_REMOTE_HOST: marvin - depends_on: - - dbserver - dbserver: - image: mariadb:latest - environment: - MYSQL_ROOT_PASSWORD: 'foobar' - MYSQL_DATABASE: ${DB_DATABASE} - MYSQL_USER: ${DB_USERNAME} - MYSQL_PASSWORD: ${DB_PASSWORD} - restart: on-failure - ports: - - "3306:3306" - volumes: - - ./.local/db:/var/lib/mysql - - ./container/db/:/docker-entrypoint-initdb.d diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 50df40c..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - ./tests - - - - - ./app - - - - - - - - - - diff --git a/public/.htaccess b/public/.htaccess deleted file mode 100644 index b75525b..0000000 --- a/public/.htaccess +++ /dev/null @@ -1,21 +0,0 @@ - - - Options -MultiViews -Indexes - - - RewriteEngine On - - # Handle Authorization Header - RewriteCond %{HTTP:Authorization} . - RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] - - # Redirect Trailing Slashes If Not A Folder... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_URI} (.+)/$ - RewriteRule ^ %1 [L,R=301] - - # Handle Front Controller... - RewriteCond %{REQUEST_FILENAME} !-d - RewriteCond %{REQUEST_FILENAME} !-f - RewriteRule ^ index.php [L] - diff --git a/public/index.php b/public/index.php deleted file mode 100644 index 04aa086..0000000 --- a/public/index.php +++ /dev/null @@ -1,28 +0,0 @@ -run(); diff --git a/public/thumbnail.php b/public/thumbnail.php deleted file mode 100644 index a6339a4..0000000 --- a/public/thumbnail.php +++ /dev/null @@ -1,41 +0,0 @@ -setImageFormat('jpeg'); - $imagick->setImageCompression(Imagick::COMPRESSION_JPEG); - $imagick->setImageCompressionQuality($quality); - $imagick->cropThumbnailImage($width, $height); - $imagick->setImagePage(0, 0, 0, 0); - if (file_put_contents($thumb, $imagick) === false) { - log_error("Could not put contents."); - } - }catch(Exception $exception){ - log_error($exception); - $imagick = file_get_contents($img); - } - - header("Content-type: image/jpeg"); - echo $imagick; - exit; -} else { - log_error("No valid image provided with {$img}."); -} - - -?> diff --git a/readme.md b/readme.md deleted file mode 100644 index 5de300b..0000000 --- a/readme.md +++ /dev/null @@ -1,9 +0,0 @@ -# lfbackend - -This is the background api of c3lf.de - -### Development server -```bash -cp .env.example .env -docker-compose up -``` diff --git a/routes/web.php b/routes/web.php deleted file mode 100644 index 5d32e71..0000000 --- a/routes/web.php +++ /dev/null @@ -1,54 +0,0 @@ -get('/', function () use ($router) { - return response()->json(array( - "framework_version" =>$router->app->version(), - "api_min_version"=>"1.0", - "api_max_version"=>"1.0", - )); -}); - - -//API version 1 -// copy this block and bump up version when implementing breaking changes -$router->group(['prefix' => '1'], function () use ($router) { - - // events - $router->get('events', ['uses' => 'EventController@showAllEvents']); - $router->get('event/{id}', ['uses' => 'EventController@showOneEvent']); - $router->post('event', ['uses' => 'EventController@create']); - $router->delete('event/{id}', ['uses' => 'EventController@delete']); - $router->put('event/{id}', ['uses' => 'EventController@update']); - - // containers - $router->get('boxes', ['uses' => 'ContainerController@showAllContainers']); - $router->get('box/{id}', ['uses' => 'ContainerController@showOneContainer']); - $router->post('box', ['uses' => 'ContainerController@create']); - $router->delete('box/{id}', ['uses' => 'ContainerController@delete']); - $router->put('box/{id}', ['uses' => 'ContainerController@update']); - - // files - $router->get('files', ['uses' => 'FileController@showAllFiles']); - $router->get('file/{id}', ['uses' => 'FileController@showOneFile']); - $router->post('file', ['uses' => 'FileController@create']); - $router->delete('file/{id}', ['uses' => 'FileController@delete']); - - // items - $router->get('{event}/items', ['uses' => 'ItemController@showByEvent']); - $router->get('{event}/items/{query}', ['uses' => 'ItemController@searchByEvent']); - $router->get('{event}/item/{id}', ['uses' => 'ItemController@showOneItem']); - $router->post('{event}/item', ['uses' => 'ItemController@create']); - $router->delete('{event}/item/{id}', ['uses' => 'ItemController@delete']); - $router->put('{event}/item/{id}', ['uses' => 'ItemController@update']); -}); diff --git a/storage/app/.gitignore b/storage/app/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/app/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore deleted file mode 100644 index 01e4a6c..0000000 --- a/storage/framework/cache/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -* -!data/ -!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/cache/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/framework/views/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/storage/logs/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/ApiTest.php b/tests/ApiTest.php deleted file mode 100644 index 76125a0..0000000 --- a/tests/ApiTest.php +++ /dev/null @@ -1,43 +0,0 @@ -get('/'); - $this->assertEquals( - $this->app->version(), $this->response->getOriginalContent()['framework_version'] - ); - } - - public function testEvents() - { - $this->get('/1/events'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - - public function testContainers() - { - $this->get('/1/boxes'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - - public function testFiles() - { - $this->get( '/1/files'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - - public function testItems() - { - Event::create(['slug'=>'TEST1','name'=>'Test Event 1']); - $this->get('/1/TEST1/items'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - -} diff --git a/tests/ContainerTest.php b/tests/ContainerTest.php deleted file mode 100644 index 529bf8f..0000000 --- a/tests/ContainerTest.php +++ /dev/null @@ -1,116 +0,0 @@ -get('/1/boxes'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - - public function testMembers() - { - Container::create(['name'=>'BOX']); - - $this->get('/1/boxes'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, count($response)); - $this->assertEquals(1, $response[0]['cid']); - $this->assertEquals('BOX', $response[0]['name']); - $this->assertEquals(0, $response[0]['itemCount']); - } - - public function testmultiMembers() - { - Container::create(['name'=>'BOX 1']); - Container::create(['name'=>'BOX 2']); - Container::create(['name'=>'BOX 3']); - - $this->get('/1/boxes'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(3, count($response)); - } - - public function testItemCount() - { - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'item_uid'=>1, 'wann'=>'', 'wo'=>'','description'=>'1']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'item_uid'=>2, 'wann'=>'', 'wo'=>'','description'=>'2']); - - $this->get('/1/boxes'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, count($response)); - $this->assertEquals(2, $response[0]['itemCount']); - } - - public function testCreateContainer(){ - $this->post('/1/box', ['name'=>'BOX']); - $response = $this->response->getOriginalContent(); - - $this->assertResponseStatus(201); - $this->assertEquals(1, $response['cid']); - $this->assertEquals('BOX', $response['name']); - $this->assertEquals(0, $response['itemCount']); - - $boxes = Container::all(); - - $this->assertEquals(1, count($boxes)); - $this->assertEquals(1, $boxes[0]['cid']); - $this->assertEquals('BOX', $boxes[0]['name']); - $this->assertEquals(0, $boxes[0]['itemCount']); - } - - public function testUpdateContainer(){ - $box = Container::create(['name'=>'BOX 1']); - - $this->assertEquals(1, $box['cid']); - $this->assertEquals('BOX 1', $box['name']); - $this->assertEquals(0, $box['itemCount']); - - $this->put('/1/box/'.$box->cid, ['name'=>'BOX 2']); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, $response['cid']); - $this->assertEquals('BOX 2', $response['name']); - $this->assertEquals(0, $response['itemCount']); - - $boxes = Container::all(); - - $this->assertEquals(1, count($boxes)); - $this->assertEquals(1, $boxes[0]['cid']); - $this->assertEquals('BOX 2', $boxes[0]['name']); - $this->assertEquals(0, $boxes[0]['itemCount']); - } - - public function testDeleteContainer(){ - $box = Container::create(['name'=>'BOX 1']); - Container::create(['name'=>'BOX 2']); - - $this->assertEquals(2, count(Container::all())); - - $this->delete('/1/box/'.$box->cid); - - $this->assertResponseOk(); - $this->assertEquals(1, count(Container::all())); - } - -} diff --git a/tests/EventTest.php b/tests/EventTest.php deleted file mode 100644 index 55c5259..0000000 --- a/tests/EventTest.php +++ /dev/null @@ -1,104 +0,0 @@ -get('/1/events'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - - public function testMembers() - { - Event::create(['slug'=>'EVENT','name'=>'Event']); - - $this->get('/1/events'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, count($response)); - $this->assertEquals(1, $response[0]['eid']); - $this->assertEquals('EVENT', $response[0]['slug']); - $this->assertEquals('Event', $response[0]['name']); - } - - public function testmultiMembers() - { - Event::create(['slug'=>'EVENT1','name'=>'Event 1']); - Event::create(['slug'=>'EVENT2','name'=>'Event 2']); - Event::create(['slug'=>'EVENT3','name'=>'Event 3']); - - $this->get('/1/events'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(3, count($response)); - } - - public function testCreateEvent() - { - $this->post('/1/event', ['slug'=>'EVENT', 'name'=>'Event']); - $response = $this->response->getOriginalContent(); - - $this->assertResponseStatus(201); - $this->assertEquals(1, $response['eid']); - $this->assertEquals('EVENT', $response['slug']); - $this->assertEquals('Event', $response['name']); - - $events = Event::all(); - - $this->assertEquals(1, count($events)); - $this->assertEquals('EVENT', $events[0]['slug']); - $this->assertEquals('Event', $events[0]['name']); - } - - public function testUpdateEvent() - { - $event = Event::create(['slug'=>'EVENT1','name'=>'Event 1']); - - $this->assertEquals(1, $event['eid']); - $this->assertEquals('EVENT1', $event['slug']); - $this->assertEquals('Event 1', $event['name']); - - $this->put('/1/event/'.$event->eid, ['slug'=>'EVENT2', 'name'=>'Event 2']); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, $response['eid']); - $this->assertEquals('EVENT2', $response['slug']); - $this->assertEquals('Event 2', $response['name']); - - $events = Event::all(); - - $this->assertEquals(1, count($events)); - $this->assertEquals('EVENT2', $events[0]['slug']); - $this->assertEquals('Event 2', $events[0]['name']); - } - - public function testRemoveEvent() - { - $event = Event::create(['slug'=>'EVENT1','name'=>'Event 1']); - Event::create(['slug'=>'EVENT2','name'=>'Event 2']); - - $this->assertEquals(1, $event['eid']); - $this->assertEquals('EVENT1', $event['slug']); - $this->assertEquals('Event 1', $event['name']); - - $this->assertEquals(2, count(Event::all())); - - $this->delete('/1/event/'.$event->eid); - - $this->assertResponseOk(); - $this->assertEquals(1, count(Event::all())); - } - -} diff --git a/tests/FileTest.php b/tests/FileTest.php deleted file mode 100644 index 0844897..0000000 --- a/tests/FileTest.php +++ /dev/null @@ -1,63 +0,0 @@ -1,'data'=>",".base64_encode("foo")]); - - $this->get( '/1/files'); - $this->assertResponseOk(); - $this->assertEquals(1, json_decode($this->response->getContent(),true)[0]['iid']); - $this->assertEquals($item->hash, json_decode($this->response->getContent(),true)[0]['hash']); - $this->assertEquals(32, strlen(json_decode($this->response->getContent(),true)[0]['hash'])); - } - - public function testOneFile(){ - $item = File::create(['iid'=>1,'data'=>",".base64_encode("foo")]); - - $this->get( '/1/file/'.$item->hash); - $this->assertResponseOk(); - $this->assertEquals(1, json_decode($this->response->getContent(),true)['iid']); - $this->assertEquals($item->hash, json_decode($this->response->getContent(),true)['hash']); - $this->assertEquals(32, strlen(json_decode($this->response->getContent(),true)['hash'])); - } - - public function testCreateFile(){ - $event = Event::create(['slug'=>'EVENT', 'name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - $item = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'2']); - - $this->post( '/1/file',['data'=>",".base64_encode("foo"), 'iid'=>$item->iid]); - $this->assertResponseStatus(201); - $this->assertEquals($item->iid, json_decode($this->response->getContent(),true)['iid']); - $this->assertEquals(32, strlen(json_decode($this->response->getContent(),true)['hash'])); - } - - public function testDeleteFile(){ - $event = Event::create(['slug'=>'EVENT', 'name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - $item = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - File::create(['iid'=>$item->iid,'data'=>",".base64_encode("foo")]); - $file = File::create(['iid'=>$item->iid,'data'=>",".base64_encode("bar")]); - - $this->assertEquals(2, count(File::all())); - - $this->delete( '/1/file/'.$file->hash); - $this->assertResponseOk(); - - $this->assertEquals(1, count(Item::all())); - } - -} diff --git a/tests/ItemTest.php b/tests/ItemTest.php deleted file mode 100644 index 163462f..0000000 --- a/tests/ItemTest.php +++ /dev/null @@ -1,173 +0,0 @@ -'EVENT','name'=>'Event']); - $this->get('/1/EVENT/items'); - $this->assertResponseOk(); - $this->assertEquals('[]',$this->response->getContent()); - } - - public function testMembers() - { - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - - $this->get('/1/EVENT/items'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, count($response)); - $this->assertEquals(1, $response[0]['uid']); - $this->assertEquals('1', $response[0]['description']); - $this->assertEquals($box->name, $response[0]['box']); - $this->assertEquals($box->cid, $response[0]['cid']); - $this->assertEquals(null, $response[0]['file']); - } - - public function testMembersWithFile() - { - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - $item = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - $file = File::create(['iid'=>$item->iid, 'data'=>",".base64_encode("foo")]); - - $this->get('/1/EVENT/items'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, count($response)); - $this->assertEquals(1, $response[0]['uid']); - $this->assertEquals('1', $response[0]['description']); - $this->assertEquals($box->name, $response[0]['box']); - $this->assertEquals($box->cid, $response[0]['cid']); - $this->assertEquals($file->hash, $response[0]['file']); - } - - public function testmultiMembers() - { - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'2']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'3']); - - $this->get('/1/EVENT/items'); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(3, count($response)); - } - - public function testCreateItem(){ - - Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - $this->post('/1/EVENT/item',['cid'=>$box->cid, 'description'=>'1']); - $response = $this->response->getOriginalContent(); - - $this->assertResponseStatus(201); - $this->assertEquals(1, $response['uid']); - $this->assertEquals('1', $response['description']); - $this->assertEquals($box->name, $response['box']); - $this->assertEquals($box->cid, $response['cid']); - //$this->assertEquals('filename', $response['file']); - - $items = Item::all(); - $this->assertEquals(1, count($items)); - $this->assertEquals(1, $items[0]['uid']); - $this->assertEquals('1', $items[0]['description']); - $this->assertEquals($box->name, $items[0]['box']); - $this->assertEquals($box->cid, $items[0]['cid']); - //$this->assertEquals('filename', $items[0]['file']); - } - - public function testCreateItemFail(){ - - Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - $this->post('/1/EVENT/item',[]); - $response = $this->response->getOriginalContent(); - - $this->assertResponseStatus(500); - } - - public function testUpdateItem(){ - - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - $item = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - - $this->assertEquals(1, $item['uid']); - $this->assertEquals('1', $item['description']); - $this->assertEquals($box->cid, $item['cid']); - - $this->put('/1/EVENT/item/'.$item->uid,['description'=>'2']); - $response = $this->response->getOriginalContent(); - - $this->assertResponseOk(); - $this->assertEquals(1, $response['uid']); - $this->assertEquals('2', $response['description']); - $this->assertEquals($box->name, $response['box']); - $this->assertEquals($box->cid, $response['cid']); - //$this->assertEquals('filename', $response['file']); - - $items = Item::all(); - $this->assertEquals(1, count($items)); - $this->assertEquals(1, $items[0]['uid']); - $this->assertEquals('2', $items[0]['description']); - $this->assertEquals($box->name, $items[0]['box']); - $this->assertEquals($box->cid, $items[0]['cid']); - //$this->assertEquals('filename', $items[0]['file']); - } - - public function testDeleteItem(){ - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - $item = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'2']); - - $this->assertEquals(2, count(Item::all())); - - $this->delete('/1/EVENT/item/'.$item->uid); - - $this->assertResponseOk(); - $this->assertEquals(1, count(Item::all())); - } - - public function testDeleteItem2(){ - $event = Event::create(['slug'=>'EVENT','name'=>'Event']); - $box = Container::create(['name'=>'BOX']); - - Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'1']); - $item2 = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'2']); - - $this->assertEquals(2, count(Item::all())); - - $this->delete('/1/EVENT/item/'.$item2->uid); - - $this->assertResponseOk(); - $this->assertEquals(1, count(Item::all())); - - $item3 = Item::create(['cid'=>$box->cid, 'eid' => $event->eid, 'description'=>'3']); - - $this->assertEquals(3, $item3['uid']); - $this->assertEquals(2, count(Item::all())); - - } - -} diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index b96aac8..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,18 +0,0 @@ -//items`` + +#### ``GET /api///items/`` +Returns all results of a fuzzy search over all items for . The should be url-encoded. + +#### ``GET /api///item/`` + +#### ``DELETE /api///item/`` + +#### ``PUT /api///item/`` + +#### ``POST /api///item`` + +### Boxes + +#### ``GET /api//boxes`` + +#### ``GET /api//box/`` + +#### ``DELETE /api//box/`` + +#### ``PUT /api//box/`` + +#### ``POST /api//box/`` + +### Events +```json +{ +"name":"36. Chaos Communication Congress", +"slug":"36C3", +"start": 1577401200, +"end": 1577746800, +"pre_start": 1576882800, +"post_end": 1577919600 +} +``` + +#### ``GET /api//events`` + +returns a list of all tracked events + +### Files/Images + +#### ``GET /api//images/`` + +#### ``GET /api//thumbs/`` \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..a58ee1b --- /dev/null +++ b/web/README.md @@ -0,0 +1,24 @@ +# c3cloc + +## Project setup +``` +yarn install +``` + +### Compiles and hot-reloads for development +``` +yarn serve +``` + +### Compiles and minifies for production +``` +yarn build +``` + +### Lints and fixes files +``` +yarn lint +``` + +### Customize configuration +See [Configuration Reference](https://cli.vuejs.org/config/). diff --git a/web/babel.config.js b/web/babel.config.js new file mode 100644 index 0000000..c5dc6a5 --- /dev/null +++ b/web/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + '@vue/cli-plugin-babel/preset' + ] +}; diff --git a/web/node_modules/.forgit_fordocker b/web/node_modules/.forgit_fordocker new file mode 100644 index 0000000..e69de29 diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a00f8ee --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,9399 @@ +{ + "name": "c3lf", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "c3lf", + "version": "0.1.0", + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/vue-fontawesome": "^3.0.6", + "base-64": "^1.0.0", + "bootstrap": "^4.3.1", + "core-js": "^3.35.1", + "jquery": "^3.4.1", + "luxon": "^1.21.3", + "popper.js": "^1.16.1", + "ramda": "^0.26.1", + "sass": "^1.19.0", + "sass-loader": "^10.4.1", + "utf8": "^3.0.0", + "vue": "^3.2.47", + "vue-router": "^4.1.6", + "vuex": "^4.1.0", + "yarn": "^1.22.21" + }, + "devDependencies": { + "@vue/cli-plugin-babel": "^5.0.8", + "@vue/cli-service": "^5.0.8", + "express-basic-auth": "^1.2.1", + "http-proxy-middleware": "^2.0.6", + "vue-template-compiler": "^2.6.10", + "webpack": "^5", + "webpack-dev-server": "^4.15.1" + } + }, + "node_modules/@achrinza/node-ipc": { + "version": "9.2.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@node-ipc/js-queue": "2.0.3", + "event-pubsub": "4.3.0", + "js-message": "1.0.7" + }, + "engines": { + "node": "8 || 9 || 10 || 11 || 12 || 13 || 14 || 15 || 16 || 17 || 18 || 19 || 20 || 21 || 22" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.2", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-decorators": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.0", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.6.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.6.0", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.6.0", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.6.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/vue-fontawesome": { + "version": "3.0.8", + "license": "MIT", + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "vue": ">= 3.0.0 < 4" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@node-ipc/js-queue": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "easy-stack": "1.0.1" + }, + "engines": { + "node": ">=1.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "dev": true, + "license": "MIT" + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@soda/friendly-errors-webpack-plugin": { + "version": "1.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^3.0.0", + "error-stack-parser": "^2.0.6", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/@soda/get-current-script": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.15", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.9.0", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.17", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vue/babel-helper-vue-jsx-merge-props": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.2.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.6", + "@babel/types": "^7.25.6", + "@vue/babel-helper-vue-transform-on": "1.2.5", + "@vue/babel-plugin-resolve-type": "1.2.5", + "html-tags": "^3.3.1", + "svg-tags": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/parser": "^7.25.6", + "@vue/compiler-sfc": "^3.5.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-plugin-transform-vue-jsx": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.0.0", + "@babel/plugin-syntax-jsx": "^7.2.0", + "@vue/babel-helper-vue-jsx-merge-props": "^1.4.0", + "html-tags": "^2.0.0", + "lodash.kebabcase": "^4.1.1", + "svg-tags": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-plugin-transform-vue-jsx/node_modules/html-tags": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@vue/babel-preset-app": { + "version": "5.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.16", + "@babel/helper-compilation-targets": "^7.12.16", + "@babel/helper-module-imports": "^7.12.13", + "@babel/plugin-proposal-class-properties": "^7.12.13", + "@babel/plugin-proposal-decorators": "^7.12.13", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-jsx": "^7.12.13", + "@babel/plugin-transform-runtime": "^7.12.15", + "@babel/preset-env": "^7.12.16", + "@babel/runtime": "^7.12.13", + "@vue/babel-plugin-jsx": "^1.0.3", + "@vue/babel-preset-jsx": "^1.1.2", + "babel-plugin-dynamic-import-node": "^2.3.3", + "core-js": "^3.8.3", + "core-js-compat": "^3.8.3", + "semver": "^7.3.4" + }, + "peerDependencies": { + "@babel/core": "*", + "core-js": "^3", + "vue": "^2 || ^3.2.13" + }, + "peerDependenciesMeta": { + "core-js": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/babel-preset-app/node_modules/semver": { + "version": "7.6.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vue/babel-preset-jsx": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/babel-helper-vue-jsx-merge-props": "^1.4.0", + "@vue/babel-plugin-transform-vue-jsx": "^1.4.0", + "@vue/babel-sugar-composition-api-inject-h": "^1.4.0", + "@vue/babel-sugar-composition-api-render-instance": "^1.4.0", + "@vue/babel-sugar-functional-vue": "^1.4.0", + "@vue/babel-sugar-inject-h": "^1.4.0", + "@vue/babel-sugar-v-model": "^1.4.0", + "@vue/babel-sugar-v-on": "^1.4.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0", + "vue": "*" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/babel-sugar-composition-api-inject-h": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-jsx": "^7.2.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-sugar-composition-api-render-instance": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-jsx": "^7.2.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-sugar-functional-vue": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-jsx": "^7.2.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-sugar-inject-h": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-jsx": "^7.2.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-sugar-v-model": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-jsx": "^7.2.0", + "@vue/babel-helper-vue-jsx-merge-props": "^1.4.0", + "@vue/babel-plugin-transform-vue-jsx": "^1.4.0", + "camelcase": "^5.0.0", + "html-tags": "^2.0.0", + "svg-tags": "^1.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-sugar-v-model/node_modules/html-tags": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@vue/babel-sugar-v-on": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-jsx": "^7.2.0", + "@vue/babel-plugin-transform-vue-jsx": "^1.4.0", + "camelcase": "^5.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/cli-overlay": { + "version": "5.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/cli-plugin-babel": { + "version": "5.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.12.16", + "@vue/babel-preset-app": "^5.0.8", + "@vue/cli-shared-utils": "^5.0.8", + "babel-loader": "^8.2.2", + "thread-loader": "^3.0.0", + "webpack": "^5.54.0" + }, + "peerDependencies": { + "@vue/cli-service": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + } + }, + "node_modules/@vue/cli-plugin-router": { + "version": "5.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/cli-shared-utils": "^5.0.8" + }, + "peerDependencies": { + "@vue/cli-service": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + } + }, + "node_modules/@vue/cli-plugin-vuex": { + "version": "5.0.8", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@vue/cli-service": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + } + }, + "node_modules/@vue/cli-service": { + "version": "5.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.12.16", + "@soda/friendly-errors-webpack-plugin": "^1.8.0", + "@soda/get-current-script": "^1.0.2", + "@types/minimist": "^1.2.0", + "@vue/cli-overlay": "^5.0.8", + "@vue/cli-plugin-router": "^5.0.8", + "@vue/cli-plugin-vuex": "^5.0.8", + "@vue/cli-shared-utils": "^5.0.8", + "@vue/component-compiler-utils": "^3.3.0", + "@vue/vue-loader-v15": "npm:vue-loader@^15.9.7", + "@vue/web-component-wrapper": "^1.3.0", + "acorn": "^8.0.5", + "acorn-walk": "^8.0.2", + "address": "^1.1.2", + "autoprefixer": "^10.2.4", + "browserslist": "^4.16.3", + "case-sensitive-paths-webpack-plugin": "^2.3.0", + "cli-highlight": "^2.1.10", + "clipboardy": "^2.3.0", + "cliui": "^7.0.4", + "copy-webpack-plugin": "^9.0.1", + "css-loader": "^6.5.0", + "css-minimizer-webpack-plugin": "^3.0.2", + "cssnano": "^5.0.0", + "debug": "^4.1.1", + "default-gateway": "^6.0.3", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "fs-extra": "^9.1.0", + "globby": "^11.0.2", + "hash-sum": "^2.0.0", + "html-webpack-plugin": "^5.1.0", + "is-file-esm": "^1.0.0", + "launch-editor-middleware": "^2.2.1", + "lodash.defaultsdeep": "^4.6.1", + "lodash.mapvalues": "^4.6.0", + "mini-css-extract-plugin": "^2.5.3", + "minimist": "^1.2.5", + "module-alias": "^2.2.2", + "portfinder": "^1.0.26", + "postcss": "^8.2.6", + "postcss-loader": "^6.1.1", + "progress-webpack-plugin": "^1.0.12", + "ssri": "^8.0.1", + "terser-webpack-plugin": "^5.1.1", + "thread-loader": "^3.0.0", + "vue-loader": "^17.0.0", + "vue-style-loader": "^4.1.3", + "webpack": "^5.54.0", + "webpack-bundle-analyzer": "^4.4.0", + "webpack-chain": "^6.5.1", + "webpack-dev-server": "^4.7.3", + "webpack-merge": "^5.7.3", + "webpack-virtual-modules": "^0.4.2", + "whatwg-fetch": "^3.6.2" + }, + "bin": { + "vue-cli-service": "bin/vue-cli-service.js" + }, + "engines": { + "node": "^12.0.0 || >= 14.0.0" + }, + "peerDependencies": { + "vue-template-compiler": "^2.0.0", + "webpack-sources": "*" + }, + "peerDependenciesMeta": { + "cache-loader": { + "optional": true + }, + "less-loader": { + "optional": true + }, + "pug-plain-loader": { + "optional": true + }, + "raw-loader": { + "optional": true + }, + "sass-loader": { + "optional": true + }, + "stylus-loader": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + }, + "webpack-sources": { + "optional": true + } + } + }, + "node_modules/@vue/cli-shared-utils": { + "version": "5.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@achrinza/node-ipc": "^9.2.5", + "chalk": "^4.1.2", + "execa": "^1.0.0", + "joi": "^17.4.0", + "launch-editor": "^2.2.1", + "lru-cache": "^6.0.0", + "node-fetch": "^2.6.7", + "open": "^8.0.2", + "ora": "^5.3.0", + "read-pkg": "^5.1.1", + "semver": "^7.3.4", + "strip-ansi": "^6.0.0" + } + }, + "node_modules/@vue/cli-shared-utils/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@vue/cli-shared-utils/node_modules/lru-cache": { + "version": "6.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vue/cli-shared-utils/node_modules/semver": { + "version": "7.6.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vue/cli-shared-utils/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.12", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.12", + "@vue/shared": "3.5.12" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.12", + "@vue/compiler-dom": "3.5.12", + "@vue/compiler-ssr": "3.5.12", + "@vue/shared": "3.5.12", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.47", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.12", + "@vue/shared": "3.5.12" + } + }, + "node_modules/@vue/component-compiler-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "consolidate": "^0.15.1", + "hash-sum": "^1.0.2", + "lru-cache": "^4.1.2", + "merge-source-map": "^1.1.0", + "postcss": "^7.0.36", + "postcss-selector-parser": "^6.0.2", + "source-map": "~0.6.1", + "vue-template-es2015-compiler": "^1.9.0" + }, + "optionalDependencies": { + "prettier": "^1.18.2 || ^2.0.0" + } + }, + "node_modules/@vue/component-compiler-utils/node_modules/hash-sum": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/component-compiler-utils/node_modules/lru-cache": { + "version": "4.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/@vue/component-compiler-utils/node_modules/picocolors": { + "version": "0.2.1", + "dev": true, + "license": "ISC" + }, + "node_modules/@vue/component-compiler-utils/node_modules/postcss": { + "version": "7.0.39", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/@vue/component-compiler-utils/node_modules/yallist": { + "version": "2.1.2", + "dev": true, + "license": "ISC" + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.12" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.12", + "@vue/shared": "3.5.12" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.12", + "@vue/runtime-core": "3.5.12", + "@vue/shared": "3.5.12", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.12", + "@vue/shared": "3.5.12" + }, + "peerDependencies": { + "vue": "3.5.12" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.12", + "license": "MIT" + }, + "node_modules/@vue/vue-loader-v15": { + "name": "vue-loader", + "version": "15.11.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/component-compiler-utils": "^3.1.0", + "hash-sum": "^1.0.2", + "loader-utils": "^1.1.0", + "vue-hot-reload-api": "^2.3.0", + "vue-style-loader": "^4.1.0" + }, + "peerDependencies": { + "css-loader": "*", + "webpack": "^3.0.0 || ^4.1.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "cache-loader": { + "optional": true + }, + "prettier": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/@vue/vue-loader-v15/node_modules/hash-sum": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/web-component-wrapper": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.12.1" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.12.1", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.12.1", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.13.1", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.12.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.12.1", + "@webassemblyjs/helper-api-error": "1.12.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.12.1", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.13.1", + "@webassemblyjs/helper-buffer": "1.13.1", + "@webassemblyjs/helper-wasm-bytecode": "1.12.1", + "@webassemblyjs/wasm-gen": "1.13.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.12.1", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.12.1", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.12.1", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.13.1", + "@webassemblyjs/helper-buffer": "1.13.1", + "@webassemblyjs/helper-wasm-bytecode": "1.12.1", + "@webassemblyjs/helper-wasm-section": "1.13.1", + "@webassemblyjs/wasm-gen": "1.13.1", + "@webassemblyjs/wasm-opt": "1.13.1", + "@webassemblyjs/wasm-parser": "1.13.1", + "@webassemblyjs/wast-printer": "1.13.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.13.1", + "@webassemblyjs/helper-wasm-bytecode": "1.12.1", + "@webassemblyjs/ieee754": "1.12.1", + "@webassemblyjs/leb128": "1.12.1", + "@webassemblyjs/utf8": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.13.1", + "@webassemblyjs/helper-buffer": "1.13.1", + "@webassemblyjs/wasm-gen": "1.13.1", + "@webassemblyjs/wasm-parser": "1.13.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.13.1", + "@webassemblyjs/helper-api-error": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.12.1", + "@webassemblyjs/ieee754": "1.12.1", + "@webassemblyjs/leb128": "1.12.1", + "@webassemblyjs/utf8": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.13.1", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.13.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-escapes": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "2.6.4", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-loader": { + "version": "8.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.4", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-loader/node_modules/loader-utils": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.2", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "dev": true, + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/bootstrap": { + "version": "4.6.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "jquery": "1.9.1 - 3", + "popper.js": "^1.16.1" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001677", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.1.1", + "execa": "^1.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "8.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.5", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/consolidate": { + "version": "0.15.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.1.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-webpack-plugin": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.7", + "glob-parent": "^6.0.1", + "globby": "^11.0.3", + "normalize-path": "^3.0.0", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/core-js": { + "version": "3.39.0", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.39.0", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-spawn": { + "version": "6.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/cross-spawn/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.6.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "dev": true, + "license": "MIT", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "1.5.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/default-gateway/node_modules/cross-spawn": { + "version": "7.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/default-gateway/node_modules/execa": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/default-gateway/node_modules/get-stream": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-gateway/node_modules/is-stream": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-gateway/node_modules/npm-run-path": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/default-gateway/node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/default-gateway/node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/default-gateway/node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/default-gateway/node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/duplexer": { + "version": "0.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/easy-stack": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.52", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-pubsub": { + "version": "4.3.0", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.21.1", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express-basic-auth": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/express/node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.3", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.17.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/figures": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "devOptional": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.6", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "11.12.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-sum": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "dev": true, + "license": "ISC" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "2.5.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "2.2.0", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "4.3.7", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-file-esm": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "read-pkg-up": "^7.0.1" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/javascript-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/jquery": { + "version": "3.7.1", + "license": "MIT" + }, + "node_modules/js-message": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/launch-editor": { + "version": "2.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/launch-editor-middleware": { + "version": "2.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "launch-editor": "^2.9.1" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "1.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/loader-utils/node_modules/json5": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaultsdeep": { + "version": "4.6.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mapvalues": { + "version": "4.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-update": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^3.0.0", + "cli-cursor": "^2.0.0", + "wrap-ansi": "^3.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/mimic-fn": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "1.28.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.12", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-source-map": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "devOptional": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/module-alias": { + "version": "2.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mrmime": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "license": "MIT", + "optional": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "dev": true, + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/popper.js": { + "version": "1.16.1", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/portfinder": { + "version": "1.0.32", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^2.6.4", + "debug": "^3.2.7", + "mkdirp": "^0.5.6" + }, + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/portfinder/node_modules/debug": { + "version": "3.2.7", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-loader": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.6.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.0", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/prettier": { + "version": "2.8.8", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/progress-webpack-plugin": { + "version": "1.0.16", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.1.0", + "figures": "^2.0.0", + "log-update": "^2.3.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "peerDependencies": { + "webpack": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/progress-webpack-plugin/node_modules/ansi-styles": { + "version": "3.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/progress-webpack-plugin/node_modules/chalk": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/progress-webpack-plugin/node_modules/color-convert": { + "version": "1.9.3", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/progress-webpack-plugin/node_modules/color-name": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/progress-webpack-plugin/node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/progress-webpack-plugin/node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pseudomap": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/pump": { + "version": "3.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/ramda": { + "version": "0.26.1", + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "6.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.11.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.11.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.8", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.80.6", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "10.5.2", + "license": "MIT", + "dependencies": { + "klona": "^2.0.4", + "loader-utils": "^2.0.0", + "neo-async": "^2.6.2", + "schema-utils": "^3.0.0", + "semver": "^7.3.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "webpack": "^4.36.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/sass-loader/node_modules/loader-utils": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/sass-loader/node_modules/schema-utils": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/sass-loader/node_modules/semver": { + "version": "7.6.3", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "dev": true, + "license": "ISC" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "dev": true, + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "dev": true, + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.20", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/spdy": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stable": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/stackframe": { + "version": "1.3.4", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-tags": { + "version": "1.0.0", + "dev": true + }, + "node_modules/svgo": { + "version": "2.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.36.0", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-loader": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-parse-better-errors": "^1.0.2", + "loader-runner": "^4.1.0", + "loader-utils": "^2.0.0", + "neo-async": "^2.6.2", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, + "node_modules/thread-loader/node_modules/loader-utils": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/thread-loader/node_modules/schema-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "0.6.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8": { + "version": "3.0.0", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vue": { + "version": "3.5.12", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.12", + "@vue/compiler-sfc": "3.5.12", + "@vue/runtime-dom": "3.5.12", + "@vue/server-renderer": "3.5.12", + "@vue/shared": "3.5.12" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-hot-reload-api": { + "version": "2.3.4", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-loader": { + "version": "17.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "hash-sum": "^2.0.0", + "watchpack": "^2.4.0" + }, + "peerDependencies": { + "webpack": "^4.1.0 || ^5.0.0-0" + }, + "peerDependenciesMeta": { + "@vue/compiler-sfc": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/vue-loader/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/vue-router": { + "version": "4.4.5", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-style-loader": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "hash-sum": "^1.0.2", + "loader-utils": "^1.0.2" + } + }, + "node_modules/vue-style-loader/node_modules/hash-sum": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-template-es2015-compiler": { + "version": "1.9.1", + "dev": true, + "license": "MIT" + }, + "node_modules/vuex": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.0.0-beta.11" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/watchpack": { + "version": "2.4.2", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "dev": true, + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.96.1", + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-chain": { + "version": "6.5.1", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "deepmerge": "^1.5.2", + "javascript-stringify": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.4", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.4.6", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "7.5.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.2", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yarn": { + "version": "1.22.22", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "bin": { + "yarn": "bin/yarn.js", + "yarnpkg": "bin/yarn.js" + }, + "engines": { + "node": ">=4.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..87f6d71 --- /dev/null +++ b/web/package.json @@ -0,0 +1,61 @@ +{ + "name": "c3lf", + "version": "0.1.0", + "private": true, + "scripts": { + "serve": "vue-cli-service serve --modern", + "build": "vue-cli-service build --modern", + "lint": "vue-cli-service lint" + }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/vue-fontawesome": "^3.0.6", + "base-64": "^1.0.0", + "bootstrap": "^4.3.1", + "core-js": "^3.35.1", + "jquery": "^3.4.1", + "luxon": "^1.21.3", + "popper.js": "^1.16.1", + "ramda": "^0.26.1", + "sass": "^1.19.0", + "sass-loader": "^10.4.1", + "utf8": "^3.0.0", + "vue": "^3.2.47", + "vue-router": "^4.1.6", + "vuex": "^4.1.0", + "yarn": "^1.22.21" + }, + "devDependencies": { + "@vue/cli-plugin-babel": "^5.0.8", + "@vue/cli-service": "^5.0.8", + "express-basic-auth": "^1.2.1", + "http-proxy-middleware": "^2.0.6", + "vue-template-compiler": "^2.6.10", + "webpack": "^5", + "webpack-dev-server": "^4.15.1" + }, + "eslintConfig": { + "root": true, + "env": { + "node": true + }, + "extends": [ + "plugin:vue/essential", + "eslint:recommended" + ], + "rules": {}, + "parserOptions": { + "parser": "babel-eslint" + } + }, + "postcss": { + "plugins": { + "autoprefixer": {} + } + }, + "browserslist": [ + "> 1%", + "last 2 versions" + ] +} diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..f693383 Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..313803b --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,18 @@ + + + + + + + + c3lf + + + +
+ + + diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..d2c9f7d --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,62 @@ + + + + + + diff --git a/web/src/assets/logo.png b/web/src/assets/logo.png new file mode 100644 index 0000000..5287100 Binary files /dev/null and b/web/src/assets/logo.png differ diff --git a/web/src/components/AddBoxModal.vue b/web/src/components/AddBoxModal.vue new file mode 100644 index 0000000..228f117 --- /dev/null +++ b/web/src/components/AddBoxModal.vue @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/AddEventModal.vue b/web/src/components/AddEventModal.vue new file mode 100644 index 0000000..ed25265 --- /dev/null +++ b/web/src/components/AddEventModal.vue @@ -0,0 +1,86 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/AddItemModal.vue b/web/src/components/AddItemModal.vue new file mode 100644 index 0000000..24bd449 --- /dev/null +++ b/web/src/components/AddItemModal.vue @@ -0,0 +1,78 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/AddTicketModal.vue b/web/src/components/AddTicketModal.vue new file mode 100644 index 0000000..b407670 --- /dev/null +++ b/web/src/components/AddTicketModal.vue @@ -0,0 +1,50 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/AsyncLoader.vue b/web/src/components/AsyncLoader.vue new file mode 100644 index 0000000..00bf841 --- /dev/null +++ b/web/src/components/AsyncLoader.vue @@ -0,0 +1,133 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/AuthenticatedDataLink.vue b/web/src/components/AuthenticatedDataLink.vue new file mode 100644 index 0000000..f121af6 --- /dev/null +++ b/web/src/components/AuthenticatedDataLink.vue @@ -0,0 +1,48 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/AuthenticatedImage.vue b/web/src/components/AuthenticatedImage.vue new file mode 100644 index 0000000..8b463b0 --- /dev/null +++ b/web/src/components/AuthenticatedImage.vue @@ -0,0 +1,68 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Cards.vue b/web/src/components/Cards.vue new file mode 100644 index 0000000..99ea98e --- /dev/null +++ b/web/src/components/Cards.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/src/components/CollapsableCards.vue b/web/src/components/CollapsableCards.vue new file mode 100644 index 0000000..d38206a --- /dev/null +++ b/web/src/components/CollapsableCards.vue @@ -0,0 +1,95 @@ + + + diff --git a/web/src/components/ExpandableTable.vue b/web/src/components/ExpandableTable.vue new file mode 100644 index 0000000..6bd5175 --- /dev/null +++ b/web/src/components/ExpandableTable.vue @@ -0,0 +1,124 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Lightbox.vue b/web/src/components/Lightbox.vue new file mode 100644 index 0000000..74e878d --- /dev/null +++ b/web/src/components/Lightbox.vue @@ -0,0 +1,33 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Matrix2D.vue b/web/src/components/Matrix2D.vue new file mode 100644 index 0000000..17e4b0c --- /dev/null +++ b/web/src/components/Matrix2D.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Matrix3D.vue b/web/src/components/Matrix3D.vue new file mode 100644 index 0000000..7a4d7f9 --- /dev/null +++ b/web/src/components/Matrix3D.vue @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Modal.vue b/web/src/components/Modal.vue new file mode 100644 index 0000000..c2c8f0b --- /dev/null +++ b/web/src/components/Modal.vue @@ -0,0 +1,75 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue new file mode 100644 index 0000000..7f5e257 --- /dev/null +++ b/web/src/components/Navbar.vue @@ -0,0 +1,160 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Table.vue b/web/src/components/Table.vue new file mode 100644 index 0000000..b9e3619 --- /dev/null +++ b/web/src/components/Table.vue @@ -0,0 +1,73 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/Timeline.vue b/web/src/components/Timeline.vue new file mode 100644 index 0000000..ee0ca4d --- /dev/null +++ b/web/src/components/Timeline.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/web/src/components/TimelineAssignment.vue b/web/src/components/TimelineAssignment.vue new file mode 100644 index 0000000..d384825 --- /dev/null +++ b/web/src/components/TimelineAssignment.vue @@ -0,0 +1,84 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineComment.vue b/web/src/components/TimelineComment.vue new file mode 100644 index 0000000..cf4bfb1 --- /dev/null +++ b/web/src/components/TimelineComment.vue @@ -0,0 +1,86 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineCreated.vue b/web/src/components/TimelineCreated.vue new file mode 100644 index 0000000..126272c --- /dev/null +++ b/web/src/components/TimelineCreated.vue @@ -0,0 +1,83 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineDeleted.vue b/web/src/components/TimelineDeleted.vue new file mode 100644 index 0000000..076467d --- /dev/null +++ b/web/src/components/TimelineDeleted.vue @@ -0,0 +1,83 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineMail.vue b/web/src/components/TimelineMail.vue new file mode 100644 index 0000000..5a76d95 --- /dev/null +++ b/web/src/components/TimelineMail.vue @@ -0,0 +1,203 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelinePlacement.vue b/web/src/components/TimelinePlacement.vue new file mode 100644 index 0000000..220be01 --- /dev/null +++ b/web/src/components/TimelinePlacement.vue @@ -0,0 +1,85 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineRelatedItem.vue b/web/src/components/TimelineRelatedItem.vue new file mode 100644 index 0000000..2670215 --- /dev/null +++ b/web/src/components/TimelineRelatedItem.vue @@ -0,0 +1,208 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineRelatedTicket.vue b/web/src/components/TimelineRelatedTicket.vue new file mode 100644 index 0000000..694a4e0 --- /dev/null +++ b/web/src/components/TimelineRelatedTicket.vue @@ -0,0 +1,94 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineReturned.vue b/web/src/components/TimelineReturned.vue new file mode 100644 index 0000000..6eb740b --- /dev/null +++ b/web/src/components/TimelineReturned.vue @@ -0,0 +1,83 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineShippingVoucher.vue b/web/src/components/TimelineShippingVoucher.vue new file mode 100644 index 0000000..fb7c575 --- /dev/null +++ b/web/src/components/TimelineShippingVoucher.vue @@ -0,0 +1,92 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/TimelineStateChange.vue b/web/src/components/TimelineStateChange.vue new file mode 100644 index 0000000..d771b9e --- /dev/null +++ b/web/src/components/TimelineStateChange.vue @@ -0,0 +1,109 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/inputs/Addon.vue b/web/src/components/inputs/Addon.vue new file mode 100644 index 0000000..0a1c5b6 --- /dev/null +++ b/web/src/components/inputs/Addon.vue @@ -0,0 +1,50 @@ + + + diff --git a/web/src/components/inputs/AsyncButton.vue b/web/src/components/inputs/AsyncButton.vue new file mode 100644 index 0000000..6ae298f --- /dev/null +++ b/web/src/components/inputs/AsyncButton.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/web/src/components/inputs/ClipboardButton.vue b/web/src/components/inputs/ClipboardButton.vue new file mode 100644 index 0000000..0f164e6 --- /dev/null +++ b/web/src/components/inputs/ClipboardButton.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/src/components/inputs/InputCombo.vue b/web/src/components/inputs/InputCombo.vue new file mode 100644 index 0000000..2a291e0 --- /dev/null +++ b/web/src/components/inputs/InputCombo.vue @@ -0,0 +1,77 @@ + + + diff --git a/web/src/components/inputs/InputPhoto.vue b/web/src/components/inputs/InputPhoto.vue new file mode 100644 index 0000000..90745a4 --- /dev/null +++ b/web/src/components/inputs/InputPhoto.vue @@ -0,0 +1,142 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/inputs/InputString.vue b/web/src/components/inputs/InputString.vue new file mode 100644 index 0000000..9586d78 --- /dev/null +++ b/web/src/components/inputs/InputString.vue @@ -0,0 +1,29 @@ + + + \ No newline at end of file diff --git a/web/src/components/inputs/SearchBox.vue b/web/src/components/inputs/SearchBox.vue new file mode 100644 index 0000000..79fb798 --- /dev/null +++ b/web/src/components/inputs/SearchBox.vue @@ -0,0 +1,57 @@ + + + \ No newline at end of file diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..f6fe706 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,56 @@ +import {createApp} from 'vue' +import App from './App.vue'; +import store from './store'; +import router from './router'; + +import 'bootstrap/dist/css/bootstrap.min.css'; +import 'bootstrap/dist/js/bootstrap.min.js'; + +// fontawesome +import {library} from '@fortawesome/fontawesome-svg-core'; +import { + faPlus, + faCheckCircle, + faEdit, + faTrash, + faCat, + faSyncAlt, + faSort, + faSortUp, + faSortDown, + faTh, + faList, + faWindowClose, + faCamera, + faStop, + faPen, + faCheck, + faTimes, + faSave, + faEye, + faComment, + faEnvelope, + faUser, + faComments, + faArchive, + faMinus, + faHourglass, + faExclamation, + faClipboard, + faTasks, + faAngleRight, + faAngleDown, + faTruck, + faObjectGroup +} from '@fortawesome/free-solid-svg-icons'; +import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'; + +library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList, + faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope, + faArchive, faMinus, faExclamation, faHourglass, faClipboard, faTasks, faAngleDown, faAngleRight, faTruck, faObjectGroup); + + +const app = createApp(App).use(store).use(router); + +app.component('font-awesome-icon', FontAwesomeIcon); +app.mount('#app') \ No newline at end of file diff --git a/web/src/mixins/data-container.js b/web/src/mixins/data-container.js new file mode 100644 index 0000000..2077540 --- /dev/null +++ b/web/src/mixins/data-container.js @@ -0,0 +1,39 @@ +import * as R from 'ramda'; + +export default { + props: ['columns', 'items', 'actions', 'keyName'], + data: (self) => ({ + sortBy: self.keyName, + ascend: true, + filters: R.fromPairs(self.columns.map(column => [column, ''])) + }), + computed: { + internalItems() { + const filtered = this.items.filter(item => this.columns + .map(column => { + const field = item[column] + ''; + const filter = this.filters[column]; + return field.toLowerCase().includes(filter.toLowerCase()); + }).reduce((acc, nxt) => acc && nxt, true) + ); + const sortByOrd = R.sortBy(R.prop(this.sortBy)); + const sorted = sortByOrd(filtered, [this.sortBy]); + return this.ascend ? sorted : R.reverse(sorted); + } + }, + methods: { + getSortIcon(column) { + if (column !== this.sortBy) return 'sort'; + if (this.ascend) return 'sort-up'; + return 'sort-down'; + }, + toggleSort(column) { + if (column === this.sortBy) + this.ascend = !this.ascend; + this.sortBy = column; + }, + setFilter(column, filter) { + this.filters[column] = filter; + } + } +}; \ No newline at end of file diff --git a/web/src/persistent-state-plugin/index.js b/web/src/persistent-state-plugin/index.js new file mode 100644 index 0000000..14f5577 --- /dev/null +++ b/web/src/persistent-state-plugin/index.js @@ -0,0 +1,74 @@ +import {isProxy, toRaw} from 'vue'; + +export default (config) => (store) => { + if (!('isLoadedKey' in config)) { + throw new Error("isLoadedKey not defined in config"); + } + + const initialize = () => { + config.state.forEach(k => { + try { + if (config.debug) console.log("localStorage init", k, localStorage.getItem(config.prefix + k)); + const parsed = JSON.parse(localStorage.getItem(config.prefix + k)); + if (parsed !== store.state[k] && parsed !== null) { + store.state[k] = parsed; + } else { + if (config.debug) console.log("localStorage not loaded", k, localStorage.getItem(config.prefix + k)); + } + } catch (e) { + if (config.debug) console.log("localStorage parse error", k, e); + } + }); + store.state[config.isLoadedKey] = true; + if ('validate' in config) { + config.validate(store.state); + } + } + + const reload = initialize; + + if (store.state[config.isLoadedKey] !== true) + initialize(); + + addEventListener('storage', reload); + + if ('state' in config) { + config.state.forEach((member) => { + store.watch((state, getters) => state[member], (newValue, oldValue) => { + try { + if (config.debug) console.log('watch', member, + isProxy(newValue) ? toRaw(newValue) : newValue, + isProxy(oldValue) ? toRaw(oldValue) : oldValue); + const key = config.prefix + member; + const encoded = JSON.stringify(isProxy(newValue) ? toRaw(newValue) : newValue); + if (encoded !== localStorage.getItem(key)) { + if (config.debug) console.log("localStorage replace", member, localStorage.getItem(key), encoded); + if (newValue === null) + localStorage.removeItem(key); + else + localStorage.setItem(key, encoded); + } else { + if (config.debug) console.log("localStorage not saved", member, localStorage.getItem(key), encoded); + } + } catch (e) { + if (config.debug) console.log("localsorage save error", member, e); + } + }); + }); + } + + if ('clearingMutation' in config) { + store.subscribe((mutation, state) => { + if (mutation.type === config.clearingMutation) { + removeEventListener('storage', reload) + for (let key in config.state) { + localStorage.removeItem(config.prefix + key); + } + for (let key in config.state) { + store.state[key] = null; + } + addEventListener('storage', reload) + } + }); + } +}; \ No newline at end of file diff --git a/web/src/router.js b/web/src/router.js new file mode 100644 index 0000000..03c1c14 --- /dev/null +++ b/web/src/router.js @@ -0,0 +1,123 @@ +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'; +import HowTo from '@/views/HowTo'; +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"; +import Events from "@/views/admin/Events.vue"; +import AccessControl from "@/views/admin/AccessControl.vue"; +import {default as BoxesAdmin} from "@/views/admin/Boxes.vue" +import Shipping from "@/views/admin/Shipping.vue"; + +const routes = [ + {path: '/', redirect: '/37C3/items', meta: {requiresAuth: false}}, + {path: '/login/', name: 'login', component: Login, meta: {requiresAuth: false}}, + {path: '/register/', name: 'register', component: Register, meta: {requiresAuth: false}}, + {path: '/howto/', name: 'howto', component: HowTo, meta: {requiresAuth: true}}, + { + 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'} + }, + { + path: '/:event/boxes/', name: 'boxes', component: Boxes, meta: + {requiresAuth: true, requiresPermission: 'view_container'} + }, + { + path: '/:event/box/:uid/', name: 'box', component: Boxes, meta: + {requiresAuth: true, requiresPermission: 'view_container'} + }, + { + 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'} + }, + { + path: '/admin/', component: Admin, meta: + {requiresAuth: true, requiresPermission: 'delete_event'}, + children: [ + { + path: 'files/', name: 'files', component: Files, meta: + {requiresAuth: true, requiresPermission: 'delete_event'} + }, + { + path: 'events/', name: 'events', component: Events, meta: + {requiresAuth: true, requiresPermission: 'delete_event'} + }, + { + path: '', name: 'admin', component: Dashboard, meta: + {requiresAuth: true, requiresPermission: 'delete_event'} + }, + { + path: 'users/', name: 'users', component: AccessControl, meta: + {requiresAuth: true, requiresPermission: 'delete_event'} + }, + { + path: 'boxes/', name: 'admin_boxes', component: BoxesAdmin, meta: + {requiresAuth: true, requiresPermission: 'delete_event'} + }, + { + path: 'shipping/', name: 'shipping', component: Shipping, meta: + {requiresAuth: true, requiresPermission: 'delete_event'} + }, + ] + }, + {path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}}, +]; + +const router = createRouter({ + history: createWebHistory(), + linkActiveClass: "active", + routes, +}); + +router.beforeEach((to, from, next) => { + if (to.meta.requiresAuth && !store.getters.isLoggedIn) { + console.log("Not logged in, redirecting to login page") + next({ + name: 'login', + query: {redirect: to.fullPath}, + }) + } else if (to.meta.requiresPermission && !store.getters.checkPermission(to.params.event || "*", to.meta.requiresPermission)) { + console.log("Not enough permissions, redirecting to empty page") + next({ + path: '/user', + }) + } else { + next() + } +}); + +router.afterEach((to, from) => { + if (to.params.event && to.params.event !== store.state.lastEvent) { + //console.log('update last event', to.params.event); + store.commit('updateLastEvent', to.params.event); + } +}); + +export default router; diff --git a/web/src/scss/navbar.scss b/web/src/scss/navbar.scss new file mode 100644 index 0000000..f74958c --- /dev/null +++ b/web/src/scss/navbar.scss @@ -0,0 +1,10 @@ +button.btn-heading { + font-size: 2em; + padding: 0 .75rem; + line-height: 1em; +} + +.btn-heading::after { + font-size: 0.5em; + line-height: 2em; +} diff --git a/web/src/shared-state-plugin/index.js b/web/src/shared-state-plugin/index.js new file mode 100644 index 0000000..67397d0 --- /dev/null +++ b/web/src/shared-state-plugin/index.js @@ -0,0 +1,345 @@ +import {isProxy, toRaw} from 'vue'; + +export default (config) => { + if (!('isLoadedKey' in config)) { + throw new Error("isLoadedKey not defined in config"); + } + if (('asyncFetch' in config) && !('lastfetched' in config)) { + throw new Error("asyncFetch defined but lastfetched not defined in config"); + } + + if (config.debug) console.log('plugin created'); + + const clone = (obj) => { + if (isProxy(obj)) { + obj = toRaw(obj); + } + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (obj.__proto__ === ({}).__proto__) { + return Object.assign({}, obj); + } + if (obj.__proto__ === [].__proto__) { + return obj.slice(); + } + return obj; + } + + const deepEqual = (a, b) => { + if (a === b) { + return true; + } + if (a === null || b === null) { + return false; + } + if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) { + + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + for (let key in b) { + if (!(key in a)) { + return false; + } + } + for (let key in a) { + if (!(key in b)) { + return false; + } + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; + } + if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + return false; + } + + const toRawRecursive = (obj) => { + if (isProxy(obj)) { + obj = toRaw(obj); + } + if (obj === null || typeof obj !== 'object') { + return obj; + } + if (obj.__proto__ === ({}).__proto__) { + const new_obj = {}; + for (let key in obj) { + new_obj[key] = toRawRecursive(obj[key]); + } + return new_obj; + } + if (obj.__proto__ === [].__proto__) { + return obj.map((item) => toRawRecursive(item)); + } + return obj; + } + + /** may only be called from worker */ + const worker_fun = function (self, ctx) { + /* globals WebSocket, SharedWorker, onconnect, onmessage, postMessage, close, location */ + + let intialized = false; + let state = {}; + let ports = []; + let notify_socket; + + const tryConnect = () => { + if (self.WebSocket === undefined) { + if (ctx.debug) console.log("no websocket support"); + return; + } + if (!notify_socket || notify_socket.readyState !== WebSocket.OPEN) { + // global location is not useful in worker loaded from data url + const scheme = ctx.location.protocol === "https:" ? "wss" : "ws"; + if (ctx.debug) console.log("connecting to", scheme + '://' + ctx.location.host + '/ws/2/notify/'); + notify_socket = new WebSocket(scheme + '://' + ctx.location.host + '/ws/2/notify/'); + notify_socket.onopen = (e) => { + if (ctx.debug) console.log("open", JSON.stringify(e)); + }; + notify_socket.onclose = (e) => { + if (ctx.debug) console.log("close", JSON.stringify(e)); + setTimeout(() => { + tryConnect(); + }, 1000); + }; + notify_socket.onerror = (e) => { + if (ctx.debug) console.log("error", JSON.stringify(e)); + setTimeout(() => { + tryConnect(); + }, 1000); + }; + notify_socket.onmessage = (e) => { + let data = JSON.parse(e.data); + if (ctx.debug) console.log("message", data); + //this.loadEventItems() + //this.loadTickets() + } + } + } + + const deepEqual = (a, b) => { + if (a === b) { + return true; + } + if (a === null || b === null) { + return false; + } + if (a.__proto__ === ({}).__proto__ && b.__proto__ === ({}).__proto__) { + + if (Object.keys(a).length !== Object.keys(b).length) { + return false; + } + for (let key in b) { + if (!(key in a)) { + return false; + } + } + for (let key in a) { + if (!(key in b)) { + return false; + } + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; + } + if (a.__proto__ === [].__proto__ && b.__proto__ === [].__proto__) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + return true; + } + return false; + } + + const handle_message = (message_data, reply, others, all) => { + switch (message_data.type) { + case 'state_init': + if (!intialized) { + intialized = true; + state = message_data.state; + reply({type: 'state_init', first: true}); + } else { + reply({type: 'state_init', first: false, state: state}); + } + break; + case 'state_diff': + if (message_data.key in state) { + if (!deepEqual(state[message_data.key], message_data.old_value)) { + if (ctx.debug) console.log("state diff old value mismatch | state:", state[message_data.key], " old:", message_data.old_value); + } + if (!deepEqual(state[message_data.key], message_data.new_value)) { + if (ctx.debug) console.log("state diff changed | state:", state[message_data.key], " new:", message_data.new_value); + state[message_data.key] = message_data.new_value; + others(message_data); + } else { + if (ctx.debug) console.log("state diff no change | state:", state[message_data.key], " new:", message_data.new_value); + } + } else { + if (ctx.debug) console.log("state diff key not found", message_data.key); + } + break; + default: + if (ctx.debug) console.log("unknown message", message_data); + } + } + + onconnect = (connect_event) => { + const port = connect_event.ports[0]; + ports.push(port); + port.onmessage = (message_event) => { + const reply = (message_data) => { + port.postMessage(message_data); + } + const others = (message_data) => { + for (let i = 0; i < ports.length; i++) { + if (ports[i] !== port) { + ports[i].postMessage(message_data); + } + } + } + const all = (message_data) => { + for (let i = 0; i < ports.length; i++) { + ports[i].postMessage(message_data); + } + } + handle_message(message_event.data, reply, others, all); + } + port.start(); + if (ctx.debug) console.log("worker connected", JSON.stringify(connect_event)); + tryConnect(); + } + + if (ctx.debug) console.log("worker loaded"); + } + + const worker_context = { + location: { + protocol: location.protocol, host: location.host + }, bug: config.debug + } + const worker_code = '(' + worker_fun.toString() + ')(self,' + JSON.stringify(worker_context) + ')'; + const worker_url = 'data:application/javascript;base64,' + btoa(worker_code); + + const worker = new SharedWorker(worker_url, 'vuex-shared-state-plugin'); + worker.port.start(); + if (config.debug) console.log('worker started'); + + const updateWorkerState = (key, new_value, old_value = null) => { + if (new_value === old_value) { + if (config.debug) console.log('updateWorkerState: no change', key, new_value); + return; + } + if (new_value === undefined) { + if (config.debug) console.log('updateWorkerState: undefined', key, new_value); + return; + } + + worker.port.postMessage({ + type: 'state_diff', + key: key, + new_value: isProxy(new_value) ? toRawRecursive(new_value) : new_value, + old_value: isProxy(old_value) ? toRawRecursive(old_value) : old_value + }); + } + + const registerInitialState = (keys, local_state) => { + const value = keys.reduce((obj, key) => { + obj[key] = isProxy(local_state[key]) ? toRawRecursive(local_state[key]) : local_state[key]; + return obj; + }, {}); + if (config.debug) console.log('registerInitilState', value); + worker.port.postMessage({ + type: 'state_init', state: value + }); + } + + return (store) => { + + worker.port.onmessage = function (e) { + switch (e.data.type) { + case 'state_init': + if (config.debug) console.log('state_init', e.data); + if (e.data.first) { + if (config.debug) console.log('worker state initialized'); + } else { + for (let key in e.data.state) { + if (key in store.state) { + if (config.debug) console.log('worker state init received', key, clone(e.data.state[key])); + if (!deepEqual(store.state[key], e.data.state[key])) { + store.state[key] = e.data.state[key]; + } + } else { + if (config.debug) console.log("state init key not found", key); + } + } + } + store.state[config.isLoadedKey] = true; + if ('afterInit' in config) { + setTimeout(() => { + store.dispatch(config.afterInit); + }, 0); + } + break; + case 'state_diff': + if (config.debug) console.log('state_diff', e.data); + if (e.data.key in store.state) { + if (config.debug) console.log('worker state update', e.data.key, clone(e.data.new_value)); + //TODO this triggers the watcher again, but we don't want that + store.state[e.data.key] = e.data.new_value; + } else { + if (config.debug) console.log("state diff key not found", e.data.key); + } + break; + default: + if (config.debug) console.log("unknown message", e.data); + } + }; + + registerInitialState(config.state, store.state); + + if ('mutations' in config) { + store.subscribe((mutation, state) => { + if (mutation.type in config.mutations) { + console.log(mutation.type, mutation.payload); + console.log(state); + } + }); + } + /*if ('actions' in config) { + store.subscribeAction((action, state) => { + if (action.type in config.actions) { + console.log(action.type, action.payload); + console.log(state); + } + }); + }*/ + if ('state' in config) { + config.watch.forEach((member) => { + store.watch((state, getters) => state[member], (newValue, oldValue) => { + if (config.debug) console.log('watch', member, clone(newValue), clone(oldValue)); + updateWorkerState(member, newValue, oldValue); + }); + }); + } + }; +} \ No newline at end of file diff --git a/web/src/store.js b/web/src/store.js new file mode 100644 index 0000000..34650e4 --- /dev/null +++ b/web/src/store.js @@ -0,0 +1,619 @@ +import {createStore} from 'vuex'; +import router from './router'; + +import * as base64 from 'base-64'; +import * as utf8 from 'utf8'; +import {ticketStateColorLookup, ticketStateIconLookup, http, http_session} from "@/utils"; +import sharedStatePlugin from "@/shared-state-plugin"; +import persistentStatePlugin from "@/persistent-state-plugin"; + +const store = createStore({ + state: { + //shared + keyIncrement: 0, + events: [], + items: [], + loadedBoxes: [], + toasts: [], + users: [], + groups: [], + state_options: [], + shippingVouchers: [], + + loadedItems: {}, + loadedTickets: {}, + + loadedItemSearchResults: {}, + loadedTicketSearchResults: {}, + + //local + lastEvent: 'all', + lastUsed: {}, + remember: false, + user: { + username: null, + password: null, + permissions: [], + token: null, + expiry: null, + }, + + lightboxHash: null, + thumbnailCache: {}, + fetchedData: { + events: 0, + items: 0, + boxes: 0, + tickets: 0, + users: 0, + groups: 0, + states: 0, + shippingVouchers: 0, + }, + persistent_loaded: false, + shared_loaded: false, + afterInitHandlers: [], + + showAddBoxModal: false, + showAddEventModal: false, + + shippingVoucherTypes: { + '2kg-de': '2kg Paket (DE)', + '5kg-de': '5kg Paket (DE)', + '10kg-de': '10kg Paket (DE)', + '5kg-eu': '5kg Paket (EU)', + '10kg-eu': '10kg Paket (EU)', + } + }, + getters: { + 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, + checkPermission: state => (event, perm) => state.user.permissions && + (state.user.permissions.includes(`${event}:${perm}`) || state.user.permissions.includes(`*:${perm}`)), + hasPermissions: state => state.user.permissions && state.user.permissions.length > 0, + activeUser: state => state.user.username || 'anonymous', + stateInfo: state => (slug) => { + const obj = state.state_options.filter((s) => s.value === slug)[0]; + if (obj) { + return { + color: ticketStateColorLookup(obj.value), + icon: ticketStateIconLookup(obj.value), + slug: obj.value, + text: obj.text, + } + } else { + return { + color: 'danger', + icon: 'exclamation', + slug: slug, + text: 'Unknown' + } + } + }, + availableShippingVoucherTypes: state => { + return Object.keys(state.shippingVoucherTypes).map(key => { + var count = state.shippingVouchers.filter(voucher => voucher.type === key && voucher.issue_thread === null).length; + return {id: key, count: count, name: state.shippingVoucherTypes[key]}; + }); + }, + layout: (state, getters) => { + if (router.currentRoute.value.query.layout) + return router.currentRoute.value.query.layout; + if (getters.getActiveView === 'items' || getters.getActiveView === 'item_search') + return 'cards'; + if (getters.getActiveView === 'tickets' || getters.getActiveView === 'ticket_search') + return 'tasks'; + }, + isLoggedIn(state) { + return state.user && state.user.username !== null && state.user.token !== null; + }, + getThumbnail: (state) => (url) => { + if (!url) return null; + if (!(url in state.thumbnailCache)) + return null; + return state.thumbnailCache[url]; + }, + }, + mutations: { + updateLastUsed(state, diff) { + state.lastUsed = {...state.lastUsed, ...diff}; + }, + updateLastEvent(state, slug) { + state.lastEvent = slug; + }, + replaceEvents(state, events) { + state.events = events; + state.fetchedData = {...state.fetchedData, events: Date.now()}; + }, + replaceTicketStates(state, states) { + state.state_options = states; + state.fetchedData = {...state.fetchedData, states: Date.now()}; + }, + changeView(state, {view, slug}) { + router.push({path: `/${slug}/${view}`}); + }, + replaceBoxes(state, loadedBoxes) { + state.loadedBoxes = loadedBoxes; + state.fetchedData = {...state.fetchedData, boxes: Date.now()}; + }, + setItems(state, {slug, items}) { + 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; + state.loadedItems = {...state.loadedItems}; + }, + updateItem(state, updatedItem) { + const item = state.loadedItems[updatedItem.event ? updatedItem.event : 'none'].filter( + ({id}) => id === updatedItem.id)[0]; + Object.assign(item, updatedItem); + }, + removeItem(state, item) { + state.loadedItems[item.event ? item.event : 'none'] = state.loadedItems[item.event].filter(it => it !== item); + }, + appendItem(state, item) { + state.loadedItems[item.event ? item.event : 'none'].push(item); + }, + setTickets(state, {slug, tickets}) { + 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; + state.loadedTickets = {...state.loadedTickets}; + }, + updateTicket(state, updatedTicket) { + const ticket = state.loadedTickets[updatedTicket.event ? updatedTicket.event : 'none'].filter( + ({id}) => id === updatedTicket.id)[0]; + Object.assign(ticket, updatedTicket); + state.loadedTickets = {...state.loadedTickets}; + }, + replaceUsers(state, users) { + state.users = users; + state.fetchedData = {...state.fetchedData, users: Date.now()}; + }, + replaceGroups(state, groups) { + state.groups = groups; + state.fetchedData = {...state.fetchedData, groups: Date.now()}; + }, + openLightboxModalWith(state, hash) { + state.lightboxHash = hash; + }, + openAddBoxModal(state) { + state.showAddBoxModal = true; + }, + closeAddBoxModal(state) { + state.showAddBoxModal = false; + }, + openAddEventModal(state) { + state.showAddEventModal = true; + }, + closeAddEventModal(state) { + state.showAddEventModal = false; + }, + createToast(state, {title, message, color}) { + var toast = {title, message, color, key: state.keyIncrement} + state.toasts.push(toast); + state.keyIncrement += 1; + return toast; + }, + removeToast(state, key) { + state.toasts = state.toasts.filter(toast => toast.key !== key); + }, + setRemember(state, remember) { + state.remember = remember; + }, + setUser(state, user) { + state.user.username = user; + }, + setPassword(state, password) { + state.user.password = password; + }, + setPermissions(state, permissions) { + state.user.permissions = permissions; + }, + setToken(state, {token, expiry}) { + const user = {...state.user}; + user.token = token; + user.expiry = expiry; + state.user = user; + }, + setUserInfo(state, user) { + state.user = user; + }, + logout(state) { + const user = {...state.user}; + user.user = null; + user.password = null; + user.token = null; + user.expiry = null; + user.permissions = null; + state.user = user; + }, + setThumbnail(state, {url, data}) { + state.thumbnailCache[url] = data; + }, + setShippingVouchers(state, codes) { + state.shippingVouchers = codes; + state.fetchedData = {...state.fetchedData, shippingVouchers: Date.now()}; + }, + }, + actions: { + async login({commit}, {username, password, remember}) { + commit('setRemember', remember); + try { + const data = await fetch('/api/2/login/', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username: username, password: password}), + credentials: 'omit' + }).then(r => r.json()) + if (data && data.token) { + const {data: {permissions}} = await http.get('/2/self/', data.token); + commit('setUserInfo', {...data, permissions, username, password}); + return true; + } else { + return false; + } + } catch (e) { + console.error(e); + return false; + } + }, + async reloadToken({commit, state}) { + try { + if (state.user.username && state.user.password) { + const data = await fetch('/api/2/login/', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username: state.user.username, password: state.user.password}), + credentials: 'omit' + }).then(r => r.json()).catch(e => console.error(e)) + if (data && data.token) { + commit('setToken', data); + return true; + } + } + } catch (e) { + console.error(e); + } + //credentials failed, logout + store.commit('logout'); + }, + //async verifyToken({commit, state}) { + async afterLogin({dispatch, state}) { + let promises = []; + promises.push(dispatch('loadBoxes')); + promises.push(dispatch('fetchTicketStates')); + promises.push(dispatch('loadEventItems')); + promises.push(dispatch('loadTickets')); + if (!state.user.permissions) { + promises.push(dispatch('loadUserInfo')); + } + await Promise.all(promises); + }, + async afterSharedInit({dispatch, state}) { + const handlers = state.afterInitHandlers; + state.afterInitHandlers = []; + await Promise.all(handlers.map(h => h()).flat()); + }, + scheduleAfterInit({dispatch, state}, handler) { + if (state.shared_loaded) { + Promise.all(handler()).then(() => { + }); + } else { + state.afterInitHandlers.push(handler); + } + }, + async fetchImage({state}, url) { + return await fetch(url, {headers: {'Authorization': `Token ${state.user.token}`}}); + }, + async loadUserInfo({commit, getters}) { + const {data, success} = await getters.session.get('/2/self/'); + commit('setPermissions', data.permissions); + }, + async loadEvents({commit, state, getters}) { + if (!state.user.token) return; + if (state.fetchedData.events > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/events/'); + if (data && success) commit('replaceEvents', data); + }, + async createEvent({commit, dispatch, state, getters}, event) { + const {data, success} = await getters.session.post('/2/events/', event); + if (data && success) commit('replaceEvents', [...state.events, data]); + }, + async deleteEvent({commit, dispatch, state, getters}, event_id) { + const {data, success} = await getters.session.delete(`/2/events/${event_id}/`); + if (success) { + await dispatch('loadEvents') + commit('replaceEvents', [...state.events.filter(e => e.id !== event_id)]) + } + }, + 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]) + } + }, + async fetchTicketStates({commit, state, getters}) { + if (!state.user.token) return; + if (state.fetchedData.states > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/tickets/states/'); + if (data && success) commit('replaceTicketStates', data); + }, + async changeEvent({dispatch, getters, commit}, eventName) { + await router.push({path: `/${eventName.slug}/${getters.getActiveView}/`}); + }, + async changeView({getters}, link) { + await router.push({path: `/${getters.getEventSlug}/${link.path}/`}); + }, + async showBoxContent({getters}, box) { + await router.push({path: `/${getters.getEventSlug}/items/`, query: {box}}); + }, + 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"); + } + } + const slug = getters.getEventSlug; + if (slug === 'all') { + await Promise.all(state.events.map(e => load(e.slug))); + } else { + await load(slug); + } + }, + 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); + } + }, + async loadBoxes({commit, state, getters}) { + if (!state.user.token) return; + if (state.fetchedData.boxes > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/boxes/'); + if (data && success) commit('replaceBoxes', data); + }, + async createBox({commit, dispatch, state, getters}, box) { + const {data, success} = await getters.session.post('/2/boxes/', box); + commit('replaceBoxes', data); + dispatch('loadBoxes').then(() => { + commit('closeAddBoxModal'); + }); + }, + async deleteBox({commit, dispatch, state, getters}, box_id) { + await getters.session.delete(`/2/boxes/${box_id}/`); + dispatch('loadBoxes'); + }, + async updateItem({commit, getters, state}, item) { + const { + data, success + } = await getters.session.put(`/2/${getters.getEventSlug}/item/${item.id}/`, item); + commit('updateItem', data); + }, + async markItemReturned({commit, getters, state}, item) { + await getters.session.patch(`/2/${getters.getEventSlug}/item/${item.id}/`, {returned: true}, + state.user.token); + commit('removeItem', item); + }, + async deleteItem({commit, getters, state}, item) { + await getters.session.delete(`/2/${getters.getEventSlug}/item/${item.id}/`, item); + commit('removeItem', item); + }, + async postItem({commit, getters, state}, item) { + commit('updateLastUsed', {box: item.box, cid: item.cid}); + const {data, success} = await getters.session.post(`/2/${getters.getEventSlug}/item/`, item); + commit('appendItem', data); + }, + async loadTickets({commit, state, getters}) { + if (!state.user.token) return; + //if (state.fetchedData.tickets > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/tickets/'); + if (data && success) commit('replaceTickets', data); + }, + 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); + } + }, + async sendMail({commit, dispatch, state, getters}, {id, message}) { + const {data, success} = await getters.session.post(`/2/tickets/${id}/reply/`, {message}, + state.user.token); + if (data && success) { + state.fetchedData.tickets = 0; + await dispatch('loadTickets'); + } + }, + 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/`, { + name: title, sender, body: message, recipient: 'mail@c3lf.de' + }); + await dispatch('loadTickets'); + }, + async postComment({commit, dispatch, state, getters}, {id, message}) { + const {data, success} = await getters.session.post(`/2/tickets/${id}/comment/`, {comment: message}); + if (data && success) { + state.fetchedData.tickets = 0; + await dispatch('loadTickets'); + } + }, + async postItemComment({commit, dispatch, state, getters}, {id, 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'); + } + }, + async loadUsers({commit, state, getters}) { + if (!state.user.token) return; + if (state.fetchedData.users > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/users/'); + if (data && success) commit('replaceUsers', data); + }, + async loadGroups({commit, state, getters}) { + if (!state.user.token) return; + if (state.fetchedData.groups > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/groups/'); + if (data && success) commit('replaceGroups', data); + }, + async updateTicket({commit, state, getters}, ticket) { + const {data, success} = await getters.session.put(`/2/tickets/${ticket.id}/`, ticket); + commit('updateTicket', data); + }, + async updateTicketPartial({commit, state, getters}, {id, ...ticket}) { + const {data, success} = await getters.session.patch(`/2/tickets/${id}/`, ticket); + commit('updateTicket', data); + }, + async fetchShippingVouchers({commit, state, getters}) { + if (!state.user.token) return; + if (state.fetchedData.shippingVouchers > Date.now() - 1000 * 60 * 60 * 24) return; + const {data, success} = await getters.session.get('/2/shipping_vouchers/'); + if (data && success) { + commit('setShippingVouchers', data); + } + }, + async createShippingVoucher({dispatch, state, getters}, code) { + const {data, success} = await getters.session.post('/2/shipping_vouchers/', code); + if (data && success) { + state.fetchedData.shippingVouchers = 0; + dispatch('fetchShippingVouchers'); + } + }, + async claimShippingVoucher({dispatch, state, getters}, {ticket, shipping_voucher_type}) { + const id = state.shippingVouchers.filter(voucher => voucher.type === shipping_voucher_type && voucher.issue_thread === null)[0].id; + const {data, success} = await getters.session.patch(`/2/shipping_vouchers/${id}/`, {issue_thread: ticket}); + if (data && success) { + state.fetchedData.shippingVouchers = 0; + 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, + isLoadedKey: "shared_loaded", + clearingMutation: "logout", + afterInit: "afterSharedInit", + state: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",], + watch: ["test", "state_options", "fetchedData", "loadedItems", "users", "groups", "loadedBoxes", "loadedTickets", "shippingVouchers",], + mutations: [//"replaceTickets", + ], + }),], +}); + +store.watch((state) => state.user, (user) => { + if (store.getters.isLoggedIn) { + if (router.currentRoute.value.name === 'login' && router.currentRoute.value.query.redirect) { + router.push(router.currentRoute.value.query.redirect); + } else if (router.currentRoute.value.name === 'login') { + router.push('/'); + } + } else { + if (router.currentRoute.value.name !== 'login') { + router.push({ + name: 'login', + query: {redirect: router.currentRoute.value.fullPath}, + }); + } + } +}); + +export default store; diff --git a/web/src/utils.js b/web/src/utils.js new file mode 100644 index 0000000..d753623 --- /dev/null +++ b/web/src/utils.js @@ -0,0 +1,138 @@ +import store from '@/store' + +function ticketStateColorLookup(ticket) { + if (ticket.startsWith('closed_')) { + return 'secondary'; + } + if (ticket.startsWith('pending_')) { + return 'warning'; + } + if (ticket.startsWith('waiting_')) { + return 'primary'; + } + return 'danger'; +} + +function ticketStateIconLookup(ticket) { + if (ticket.startsWith('closed_')) { + return 'check'; + } + if (ticket.startsWith('pending_')) { + return 'exclamation'; + } + if (ticket.startsWith('waiting_')) { + return 'hourglass'; + } + return 'exclamation'; +} + +const http = { + get: async (url, token) => { + if (!token) { + return null; + } + const response = await fetch('/api' + url, { + method: 'GET', + headers: { + "Content-Type": "application/json", + "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}; + }, + post: async (url, data, token) => { + if (!token) { + return null; + } + const response = await fetch('/api' + url, { + method: 'POST', + headers: { + "Content-Type": "application/json", + "Authorization": `Token ${token}`, + }, + 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}; + }, + put: async (url, data, token) => { + if (!token) { + return null; + } + const response = await fetch('/api' + url, { + method: 'PUT', + headers: { + "Content-Type": "application/json", + "Authorization": `Token ${token}`, + }, + 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}; + }, + patch: async (url, data, token) => { + if (!token) { + return null; + } + const response = await fetch('/api' + url, { + method: 'PATCH', + headers: { + "Content-Type": "application/json", + "Authorization": `Token ${token}`, + }, + 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}; + }, + delete: async (url, token) => { + if (!token) { + return null; + } + const response = await fetch('/api' + url, { + method: 'DELETE', + headers: { + "Content-Type": "application/json", + "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}; + }), +}); + +export {ticketStateColorLookup, ticketStateIconLookup, http, http_session}; \ No newline at end of file diff --git a/web/src/views/Boxes.vue b/web/src/views/Boxes.vue new file mode 100644 index 0000000..5de5b26 --- /dev/null +++ b/web/src/views/Boxes.vue @@ -0,0 +1,39 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Empty.vue b/web/src/views/Empty.vue new file mode 100644 index 0000000..ef221da --- /dev/null +++ b/web/src/views/Empty.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/web/src/views/Error.vue b/web/src/views/Error.vue new file mode 100644 index 0000000..73e63e5 --- /dev/null +++ b/web/src/views/Error.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/web/src/views/Files.vue b/web/src/views/Files.vue new file mode 100644 index 0000000..aac8c7e --- /dev/null +++ b/web/src/views/Files.vue @@ -0,0 +1,37 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/HowTo.vue b/web/src/views/HowTo.vue new file mode 100644 index 0000000..8e4526d --- /dev/null +++ b/web/src/views/HowTo.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/web/src/views/Item.vue b/web/src/views/Item.vue new file mode 100644 index 0000000..7b9f484 --- /dev/null +++ b/web/src/views/Item.vue @@ -0,0 +1,184 @@ + + + + + + diff --git a/web/src/views/ItemSearch.vue b/web/src/views/ItemSearch.vue new file mode 100644 index 0000000..8e9dcc4 --- /dev/null +++ b/web/src/views/ItemSearch.vue @@ -0,0 +1,114 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Items.vue b/web/src/views/Items.vue new file mode 100644 index 0000000..72b4079 --- /dev/null +++ b/web/src/views/Items.vue @@ -0,0 +1,111 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..d002cc6 --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,120 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Register.vue b/web/src/views/Register.vue new file mode 100644 index 0000000..15412f7 --- /dev/null +++ b/web/src/views/Register.vue @@ -0,0 +1,169 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Ticket.vue b/web/src/views/Ticket.vue new file mode 100644 index 0000000..6a195f6 --- /dev/null +++ b/web/src/views/Ticket.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/web/src/views/TicketSearch.vue b/web/src/views/TicketSearch.vue new file mode 100644 index 0000000..f5a4a7d --- /dev/null +++ b/web/src/views/TicketSearch.vue @@ -0,0 +1,102 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/Tickets.vue b/web/src/views/Tickets.vue new file mode 100644 index 0000000..60d0d1d --- /dev/null +++ b/web/src/views/Tickets.vue @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/admin/AccessControl.vue b/web/src/views/admin/AccessControl.vue new file mode 100644 index 0000000..339e35a --- /dev/null +++ b/web/src/views/admin/AccessControl.vue @@ -0,0 +1,68 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/admin/Admin.vue b/web/src/views/admin/Admin.vue new file mode 100644 index 0000000..5a71223 --- /dev/null +++ b/web/src/views/admin/Admin.vue @@ -0,0 +1,49 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/admin/Boxes.vue b/web/src/views/admin/Boxes.vue new file mode 100644 index 0000000..3355b0c --- /dev/null +++ b/web/src/views/admin/Boxes.vue @@ -0,0 +1,47 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/admin/Dashboard.vue b/web/src/views/admin/Dashboard.vue new file mode 100644 index 0000000..6272d71 --- /dev/null +++ b/web/src/views/admin/Dashboard.vue @@ -0,0 +1,33 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/admin/Events.vue b/web/src/views/admin/Events.vue new file mode 100644 index 0000000..e156d56 --- /dev/null +++ b/web/src/views/admin/Events.vue @@ -0,0 +1,104 @@ + + + + + \ No newline at end of file diff --git a/web/src/views/admin/Shipping.vue b/web/src/views/admin/Shipping.vue new file mode 100644 index 0000000..4ba1ed3 --- /dev/null +++ b/web/src/views/admin/Shipping.vue @@ -0,0 +1,99 @@ + + + + + \ No newline at end of file diff --git a/web/vue.config.js b/web/vue.config.js new file mode 100644 index 0000000..6a50fe3 --- /dev/null +++ b/web/vue.config.js @@ -0,0 +1,31 @@ +// vue.config.js + +module.exports = { + devServer: { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Access-Control-Allow-Methods": "*" + }, + proxy: { + '^/media/2': { + target: 'https://staging.c3lf.de/', + changeOrigin: true + }, + '^/api/2': { + target: 'https://staging.c3lf.de/', + changeOrigin: true, + }, + '^/api/1': { + target: 'https://staging.c3lf.de/', + changeOrigin: true, + }, + '^/ws/2': { + target: 'http://127.0.0.1:8082/', + //changeOrigin: true, + ws: true, + logLevel: 'debug', + }, + } + } +} \ No newline at end of file