diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6f313c6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +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 new file mode 100644 index 0000000..ac941f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +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 deleted file mode 100644 index 8b227a0..0000000 --- a/.forgejo/issue_template/bug.yml +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index c8cf794..0000000 --- a/.forgejo/issue_template/feature.yml +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 4c5fcd0..0000000 --- a/.forgejo/workflows/deploy_staging.yml +++ /dev/null @@ -1,60 +0,0 @@ -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 deleted file mode 100644 index 480e590..0000000 --- a/.forgejo/workflows/test.yml +++ /dev/null @@ -1,21 +0,0 @@ -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 9f86c99..159c028 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,15 @@ - +/vendor /.idea +Homestead.json +Homestead.yaml .env .local +/public/docs +/public/staticimages +/public/thumbnails +/resources/docs -staticfiles/ -userfiles/ -*.db \ No newline at end of file +composer.lock +composer.phar + +.phpunit.result.cache diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..62d2991 --- /dev/null +++ b/.styleci.yml @@ -0,0 +1,9 @@ +php: + preset: laravel + enabled: + - alpha_ordered_imports + disabled: + - length_ordered_imports + - unused_use +js: true +css: true diff --git a/core/authentication/__init__.py b/app/Console/Commands/.gitkeep similarity index 100% rename from core/authentication/__init__.py rename to app/Console/Commands/.gitkeep diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 0000000..ad6e311 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..2f99288 --- /dev/null +++ b/app/Event.php @@ -0,0 +1,27 @@ +create($attributes); + } + + + + +} diff --git a/app/Http/Controllers/ContainerController.php b/app/Http/Controllers/ContainerController.php new file mode 100644 index 0000000..7982612 --- /dev/null +++ b/app/Http/Controllers/ContainerController.php @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..0ccb918 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..61840db --- /dev/null +++ b/app/Http/Controllers/FileController.php @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..1c94846 --- /dev/null +++ b/app/Http/Controllers/ItemController.php @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..361a11e --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..166581c --- /dev/null +++ b/app/Http/Middleware/ExampleMiddleware.php @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..7b65bb4 --- /dev/null +++ b/app/Jobs/ExampleJob.php @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..a3d284f --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,19 @@ + [ + 'App\Listeners\ExampleListener', + ], + ]; +} diff --git a/app/User.php b/app/User.php new file mode 100644 index 0000000..0284e3d --- /dev/null +++ b/app/User.php @@ -0,0 +1,32 @@ +make( + 'Illuminate\Contracts\Console\Kernel' +); + +exit($kernel->handle(new ArgvInput, new ConsoleOutput)); diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..bb4b4ad --- /dev/null +++ b/backup.sh @@ -0,0 +1,33 @@ +#!/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 new file mode 100644 index 0000000..48de537 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,102 @@ +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 new file mode 100644 index 0000000..4ee5c32 --- /dev/null +++ b/composer.json @@ -0,0 +1,44 @@ +{ + "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 new file mode 100644 index 0000000..cb46b7f --- /dev/null +++ b/config/apidoc.php @@ -0,0 +1,245 @@ + '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 new file mode 100644 index 0000000..5c44334 --- /dev/null +++ b/config/database.php @@ -0,0 +1,42 @@ + 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 new file mode 100644 index 0000000..72a43cf --- /dev/null +++ b/container/db/01_init.sql @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..3e00bb2 --- /dev/null +++ b/container/web/general.conf @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..1d36242 --- /dev/null +++ b/container/web/images.conf @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..0176b21 --- /dev/null +++ b/container/web/init.sh @@ -0,0 +1,8 @@ +#!/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 new file mode 100644 index 0000000..a8b6892 --- /dev/null +++ b/container/web/location-root.conf @@ -0,0 +1,15 @@ +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 deleted file mode 100644 index 14c1fba..0000000 --- a/core/.coveragerc +++ /dev/null @@ -1,14 +0,0 @@ -[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 deleted file mode 100644 index 9fe17bc..0000000 --- a/core/.gitignore +++ /dev/null @@ -1,129 +0,0 @@ -# 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/core/authentication/admin.py b/core/authentication/admin.py deleted file mode 100644 index 0024cb0..0000000 --- a/core/authentication/admin.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 2547b6d..0000000 --- a/core/authentication/api_v2.py +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index d659962..0000000 --- a/core/authentication/migrations/0001_initial.py +++ /dev/null @@ -1,67 +0,0 @@ -# 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 deleted file mode 100644 index 383aafd..0000000 --- a/core/authentication/migrations/0002_authtokeneventpermissions_extendedauthtoken_and_more.py +++ /dev/null @@ -1,45 +0,0 @@ -# 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 deleted file mode 100644 index c8f299d..0000000 --- a/core/authentication/migrations/0003_groups.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index 76f62dd..0000000 --- a/core/authentication/migrations/0004_legacy_user.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 85258af..0000000 --- a/core/authentication/migrations/0005_alter_eventpermission_event_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# 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/core/authentication/models.py b/core/authentication/models.py deleted file mode 100644 index 0de8e6a..0000000 --- a/core/authentication/models.py +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 0581865..0000000 --- a/core/authentication/serializers.py +++ /dev/null @@ -1,32 +0,0 @@ -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/v2/__init__.py b/core/authentication/tests/v2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/authentication/tests/v2/test_permissions.py b/core/authentication/tests/v2/test_permissions.py deleted file mode 100644 index 0a00fcd..0000000 --- a/core/authentication/tests/v2/test_permissions.py +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 125be9b..0000000 --- a/core/authentication/tests/v2/test_users.py +++ /dev/null @@ -1,183 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/core/asgi.py b/core/core/asgi.py deleted file mode 100644 index 9d7fde2..0000000 --- a/core/core/asgi.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -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 deleted file mode 100644 index d84a7a0..0000000 --- a/core/core/globals.py +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 149829c..0000000 --- a/core/core/metrics.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.apps import apps -from prometheus_client.core import CounterMetricFamily, REGISTRY -from django.db.models import Case, Value, When, BooleanField, Count -from inventory.models import Item - -class ItemCountCollector(object): - - def collect(self): - 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 - -REGISTRY.register(ItemCountCollector()) \ No newline at end of file diff --git a/core/core/settings.py b/core/core/settings.py deleted file mode 100644 index 805a27b..0000000 --- a/core/core/settings.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -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 deleted file mode 100644 index bd7f9eb..0000000 --- a/core/core/test_runner.py +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 2386891..0000000 --- a/core/core/urls.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -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 deleted file mode 100644 index 64d3bde..0000000 --- a/core/core/version.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/files/admin.py b/core/files/admin.py deleted file mode 100644 index 3584ed5..0000000 --- a/core/files/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index a0962f0..0000000 --- a/core/files/api_v2.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 00161b3..0000000 --- a/core/files/media_v2.py +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 2c15fff..0000000 --- a/core/files/migrations/0001_initial.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index 6a0c33e..0000000 --- a/core/files/migrations/0002_alter_file_file.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 63e5760..0000000 --- a/core/files/migrations/0003_ensure_creation_date.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.7 on 2024-11-21 22:40 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ('files', '0002_alter_file_file'), - ] - - def set_creation_date(apps, schema_editor): - File = apps.get_model('files', 'File') - for file in File.objects.all(): - if file.created_at is None: - if not file.item.created_at is None: - file.created_at = file.item.created_at - else: - file.created_at = max(File.objects.filter( - id__lt=file.id, created_at__isnull=False).values_list('created_at', flat=True)) - file.save() - - operations = [ - migrations.RunPython(set_creation_date), - ] diff --git a/core/files/migrations/__init__.py b/core/files/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/files/models.py b/core/files/models.py deleted file mode 100644 index 33a6265..0000000 --- a/core/files/models.py +++ /dev/null @@ -1,95 +0,0 @@ -from django.core.files.base import ContentFile -from django.db import models, IntegrityError -from django_softdelete.models import SoftDeleteModel - -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 get_or_create(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') - 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): - if 'data' in kwargs and type(kwargs['data']) == str: - import base64 - from hashlib import sha256 - raw = kwargs['data'] - if not raw.startswith('data:'): - raise ValueError('data must be a base64 encoded string or file and hash must be provided') - raw = raw.split(';base64,') - if len(raw) != 2: - raise ValueError('data must be a base64 encoded string or file and hash must be provided') - mime_type = raw[0].split(':')[1] - content = base64.b64decode(raw[1], validate=True) - kwargs.pop('data') - content_hash = sha256(content).hexdigest() - kwargs['file'] = ContentFile(content, content_hash) - kwargs['hash'] = content_hash - kwargs['mime_type'] = mime_type - elif 'file' in kwargs and 'hash' in kwargs and type(kwargs['file']) == ContentFile and 'mime_type' in kwargs: - pass - else: - raise ValueError('data must be a base64 encoded string or file and hash must be provided') - if not self.filter(hash=kwargs['hash']).exists(): - obj = super().create(**kwargs) - obj.file.save(content=kwargs['file'], name=kwargs['hash']) - 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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/files/tests/v2/__init__.py b/core/files/tests/v2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/files/tests/v2/test_files.py b/core/files/tests/v2/test_files.py deleted file mode 100644 index dedd647..0000000 --- a/core/files/tests/v2/test_files.py +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index ae3c53b..0000000 --- a/core/helper.py +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/inventory/admin.py b/core/inventory/admin.py deleted file mode 100644 index 24cd14a..0000000 --- a/core/inventory/admin.py +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 04c1722..0000000 --- a/core/inventory/api_v2.py +++ /dev/null @@ -1,205 +0,0 @@ -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 deleted file mode 100644 index bd08ef3..0000000 --- a/core/inventory/migrations/0001_initial.py +++ /dev/null @@ -1,54 +0,0 @@ -# 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 deleted file mode 100644 index 28523a7..0000000 --- a/core/inventory/migrations/0002_container_deleted_at_container_is_deleted_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index e20b525..0000000 --- a/core/inventory/migrations/0003_alter_item_options.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index b5fd81a..0000000 --- a/core/inventory/migrations/0004_alter_event_created_at_alter_item_created_at.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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 deleted file mode 100644 index fcd4b8d..0000000 --- a/core/inventory/migrations/0005_rename_cid_container_id_rename_eid_event_id_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index 2fa421a..0000000 --- a/core/inventory/migrations/0006_alter_event_table.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 918f636..0000000 --- a/core/inventory/migrations/0007_rename_container_item_container_old_itemplacement_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/inventory/models.py b/core/inventory/models.py deleted file mode 100644 index 231094e..0000000 --- a/core/inventory/models.py +++ /dev/null @@ -1,117 +0,0 @@ -from itertools import groupby - -from django.db import models -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 - - -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 deleted file mode 100644 index 2aa1135..0000000 --- a/core/inventory/serializers.py +++ /dev/null @@ -1,148 +0,0 @@ -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 - }) - 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 deleted file mode 100644 index 2cafd5f..0000000 --- a/core/inventory/shared_serializers.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/inventory/tests/v2/__init__.py b/core/inventory/tests/v2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/inventory/tests/v2/test_api.py b/core/inventory/tests/v2/test_api.py deleted file mode 100644 index 6904164..0000000 --- a/core/inventory/tests/v2/test_api.py +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index b74a4a7..0000000 --- a/core/inventory/tests/v2/test_containers.py +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index bd58515..0000000 --- a/core/inventory/tests/v2/test_events.py +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 0c85eb4..0000000 --- a/core/inventory/tests/v2/test_items.py +++ /dev/null @@ -1,345 +0,0 @@ -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']), 4) - self.assertEqual(response.json()[0]['timeline'][0]['type'], 'placement') - self.assertEqual(response.json()[0]['timeline'][1]['type'], 'comment') - self.assertEqual(response.json()[0]['timeline'][2]['type'], 'issue_relation') - self.assertEqual(response.json()[0]['timeline'][3]['type'], 'placement') - self.assertEqual(response.json()[0]['timeline'][1]['id'], comment.id) - self.assertEqual(response.json()[0]['timeline'][2]['id'], match.id) - self.assertEqual(response.json()[0]['timeline'][3]['id'], placement.id) - self.assertEqual(response.json()[0]['timeline'][0]['box'], 'BOX1') - self.assertEqual(response.json()[0]['timeline'][0]['cid'], self.box1.id) - self.assertEqual(response.json()[0]['timeline'][1]['comment'], 'test') - self.assertEqual(response.json()[0]['timeline'][1]['timestamp'], - comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) - self.assertEqual(response.json()[0]['timeline'][2]['status'], 'possible') - self.assertEqual(response.json()[0]['timeline'][2]['timestamp'], - match.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) - self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['name'], "test issue") - self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['event'], "EVENT") - self.assertEqual(response.json()[0]['timeline'][2]['issue_thread']['state'], "pending_new") - self.assertEqual(response.json()[0]['timeline'][3]['box'], 'BOX2') - self.assertEqual(response.json()[0]['timeline'][3]['cid'], self.box2.id) - self.assertEqual(response.json()[0]['timeline'][3]['timestamp'], - placement.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) - self.assertEqual(len(response.json()[0]['related_issues']), 1) - self.assertEqual(response.json()[0]['related_issues'][0]['name'], "test issue") - 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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/mail/admin.py b/core/mail/admin.py deleted file mode 100644 index 55619c2..0000000 --- a/core/mail/admin.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0d96785..0000000 --- a/core/mail/api_v2.py +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 71c54e7..0000000 --- a/core/mail/migrations/0001_initial.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index ce0d5da..0000000 --- a/core/mail/migrations/0002_printed_quotable.py +++ /dev/null @@ -1,32 +0,0 @@ -# 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 deleted file mode 100644 index 7796876..0000000 --- a/core/mail/migrations/0003_emailattachment.py +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index 4342c8e..0000000 --- a/core/mail/migrations/0004_alter_emailattachment_file.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 30b79bf..0000000 --- a/core/mail/migrations/0005_alter_eventaddress_event.py +++ /dev/null @@ -1,20 +0,0 @@ -# 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 deleted file mode 100644 index 4086af8..0000000 --- a/core/mail/migrations/0006_email_raw_file.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/mail/models.py b/core/mail/models.py deleted file mode 100644 index 36cd2b3..0000000 --- a/core/mail/models.py +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index 258334c..0000000 --- a/core/mail/protocol.py +++ /dev/null @@ -1,330 +0,0 @@ -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) - - reply.set_content(reply_email.body) - - return reply - - -async def send_smtp(message): - await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False) - - -def find_active_issue_thread(in_reply_to, address, subject, event, spam=False): - 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 deleted file mode 100644 index 312b916..0000000 --- a/core/mail/socket.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/mail/tests/v2/__init__.py b/core/mail/tests/v2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/mail/tests/v2/test_mails.py b/core/mail/tests/v2/test_mails.py deleted file mode 100644 index 95d35cb..0000000 --- a/core/mail/tests/v2/test_mails.py +++ /dev/null @@ -1,1204 +0,0 @@ -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 deleted file mode 100755 index f2a662c..0000000 --- a/core/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/notify_sessions/admin.py b/core/notify_sessions/admin.py deleted file mode 100644 index 5f518e7..0000000 --- a/core/notify_sessions/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 30ff737..0000000 --- a/core/notify_sessions/api_v2.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 06413b5..0000000 --- a/core/notify_sessions/consumers.py +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index c116acf..0000000 --- a/core/notify_sessions/migrations/0001_initial.py +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/notify_sessions/models.py b/core/notify_sessions/models.py deleted file mode 100644 index 095f1b5..0000000 --- a/core/notify_sessions/models.py +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 1aa0190..0000000 --- a/core/notify_sessions/routing.py +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/notify_sessions/tests/test_notify_socket.py b/core/notify_sessions/tests/test_notify_socket.py deleted file mode 100644 index 4e971f1..0000000 --- a/core/notify_sessions/tests/test_notify_socket.py +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 3807a6c..0000000 --- a/core/requirements.dev.txt +++ /dev/null @@ -1,77 +0,0 @@ -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==10.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 -watchfiles==0.21.0 -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 deleted file mode 100644 index ee69fe7..0000000 --- a/core/requirements.prod.txt +++ /dev/null @@ -1,45 +0,0 @@ -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 deleted file mode 100644 index d08b595..0000000 --- a/core/server.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/tickets/admin.py b/core/tickets/admin.py deleted file mode 100644 index d842482..0000000 --- a/core/tickets/admin.py +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index d5fd5fe..0000000 --- a/core/tickets/api_v2.py +++ /dev/null @@ -1,241 +0,0 @@ -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'], - ) - systemevent = SystemEvent.objects.create(type='email received', reference=email.id) - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id, - "message": "email received"} - ) - - return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) - - -@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'], - ) - systemevent = SystemEvent.objects.create(type='comment added', reference=comment.id) - channel_layer = get_channel_layer() - async_to_sync(channel_layer.group_send)( - 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id, - "message": "comment added"} - ) - return Response(CommentSerializer(comment).data, status=status.HTTP_201_CREATED) - - -def filter_issues(issues, query): - query_tokens = query.lower().split(' ') - matches = [] - 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 deleted file mode 100644 index 475d70c..0000000 --- a/core/tickets/migrations/0001_initial.py +++ /dev/null @@ -1,48 +0,0 @@ -# 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 deleted file mode 100644 index 3c1e4a7..0000000 --- a/core/tickets/migrations/0002_alter_issuethread_options_and_more.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index dd2c9bc..0000000 --- a/core/tickets/migrations/0003_alter_issuethread_state.py +++ /dev/null @@ -1,39 +0,0 @@ -# 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 deleted file mode 100644 index 7fda407..0000000 --- a/core/tickets/migrations/0004_remove_issuethread_state_alter_statechange_state.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index 37a03ef..0000000 --- a/core/tickets/migrations/0005_remove_issuethread_last_activity.py +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 6618bbe..0000000 --- a/core/tickets/migrations/0006_issuethread_uuid.py +++ /dev/null @@ -1,30 +0,0 @@ -# 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 deleted file mode 100644 index 6a105a7..0000000 --- a/core/tickets/migrations/0007_alter_statechange_state.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 788f9f6..0000000 --- a/core/tickets/migrations/0008_alter_issuethread_options_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index a857457..0000000 --- a/core/tickets/migrations/0009_shippingvoucher.py +++ /dev/null @@ -1,25 +0,0 @@ -# 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 deleted file mode 100644 index 06ec4fd..0000000 --- a/core/tickets/migrations/0010_issuethread_event_itemrelation_and_more.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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 deleted file mode 100644 index 206cbb4..0000000 --- a/core/tickets/migrations/0011_train_old_spam.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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 deleted file mode 100644 index d8a24c7..0000000 --- a/core/tickets/migrations/0012_remove_issuethread_related_items_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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/__init__.py b/core/tickets/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/tickets/models.py b/core/tickets/models.py deleted file mode 100644 index 794c8e4..0000000 --- a/core/tickets/models.py +++ /dev/null @@ -1,179 +0,0 @@ -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 deleted file mode 100644 index ff695b1..0000000 --- a/core/tickets/serializers.py +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 3d46013..0000000 --- a/core/tickets/shared_serializers.py +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/core/tickets/tests/v2/__init__.py b/core/tickets/tests/v2/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/core/tickets/tests/v2/test_matches.py b/core/tickets/tests/v2/test_matches.py deleted file mode 100644 index 7bd7a52..0000000 --- a/core/tickets/tests/v2/test_matches.py +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index 45fa245..0000000 --- a/core/tickets/tests/v2/test_shipping_vouchers.py +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index d7bb346..0000000 --- a/core/tickets/tests/v2/test_tickets.py +++ /dev/null @@ -1,491 +0,0 @@ -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 new file mode 100644 index 0000000..bf9496b --- /dev/null +++ b/database/factories/ModelFactory.php @@ -0,0 +1,19 @@ +define(App\User::class, function (Faker\Generator $faker) { + return [ + 'name' => $faker->name, + 'email' => $faker->email, + ]; +}); diff --git a/core/authentication/migrations/__init__.py b/database/migrations/.gitkeep similarity index 100% rename from core/authentication/migrations/__init__.py rename to database/migrations/.gitkeep diff --git a/database/migrations/2019_11_14_213133_create_events_table.php b/database/migrations/2019_11_14_213133_create_events_table.php new file mode 100644 index 0000000..7c0d7cc --- /dev/null +++ b/database/migrations/2019_11_14_213133_create_events_table.php @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..2ee7e5a --- /dev/null +++ b/database/migrations/2019_11_14_214212_create_containers_table.php @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..164ecd1 --- /dev/null +++ b/database/migrations/2019_11_14_232911_create_items_table.php @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..e5da094 --- /dev/null +++ b/database/migrations/2019_11_28_035156_create_files_table.php @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..45bb207 --- /dev/null +++ b/database/migrations/2019_11_29_005038_container_add_soft_deletes.php @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..202cab0 --- /dev/null +++ b/database/migrations/2019_12_15_203050_rename__itemfields.php @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..1e87a6a --- /dev/null +++ b/database/migrations/2019_12_24_134319_delete_id_on_file.php @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..aa4fb03 --- /dev/null +++ b/database/migrations/2019_12_27_193619_add_item_returned_at_member.php @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..7220cb3 --- /dev/null +++ b/database/migrations/2020_01_18_001554_add_currentfiles_view.php @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..23526c9 --- /dev/null +++ b/database/seeds/DatabaseSeeder.php @@ -0,0 +1,16 @@ +call('UsersTableSeeder'); + } +} diff --git a/deploy/.gitignore b/deploy/.gitignore deleted file mode 100644 index 611281b..0000000 --- a/deploy/.gitignore +++ /dev/null @@ -1 +0,0 @@ -inventory.yml \ No newline at end of file diff --git a/deploy/ansible/inventory.yml.sample b/deploy/ansible/inventory.yml.sample deleted file mode 100644 index 2a50efd..0000000 --- a/deploy/ansible/inventory.yml.sample +++ /dev/null @@ -1,16 +0,0 @@ ---- -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 deleted file mode 100644 index 4005146..0000000 --- a/deploy/ansible/playbooks/deploy-c3lf-sys3.yml +++ /dev/null @@ -1,370 +0,0 @@ -- 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 deleted file mode 100644 index 515a82d..0000000 --- a/deploy/ansible/playbooks/templates/c3lf-sys3.service.j2 +++ /dev/null @@ -1,18 +0,0 @@ -[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 deleted file mode 100644 index b95ac7e..0000000 --- a/deploy/ansible/playbooks/templates/config.js.j2 +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index c9b1c83..0000000 --- a/deploy/ansible/playbooks/templates/django.env.j2 +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 1bde29a..0000000 --- a/deploy/ansible/playbooks/templates/nginx.conf.j2 +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index f6e0b09..0000000 --- a/deploy/ansible/playbooks/templates/postfix.cf.j2 +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index 9e21aa5..0000000 --- a/deploy/ansible/playbooks/templates/rspamd-dkim.cf.j2 +++ /dev/null @@ -1,79 +0,0 @@ -# local.d/dkim_signing.conf - -enabled = true; - -# If false, messages with empty envelope from are not signed -allow_envfrom_empty = true; - -# If true, envelope/header domain mismatch is ignored -allow_hdrfrom_mismatch = false; - -# If true, multiple from headers are allowed (but only first is used) -allow_hdrfrom_multiple = false; - -# If true, username does not need to contain matching domain -allow_username_mismatch = false; - -# Default path to key, can include '$domain' and '$selector' variables -path = "/var/lib/rspamd/dkim/$domain.$selector.key"; - -# Default selector to use -selector = "dkim"; - -# If false, messages from authenticated users are not selected for signing -sign_authenticated = true; - -# If false, messages from local networks are not selected for signing -sign_local = true; - -# Map file of IP addresses/subnets to consider for signing -# sign_networks = "/some/file"; # or url - -# Symbol to add when message is signed -symbol = "DKIM_SIGNED"; - -# Whether to fallback to global config -try_fallback = true; - -# Domain to use for DKIM signing: can be "header" (MIME From), "envelope" (SMTP From), "recipient" (SMTP To), "auth" (SMTP username) or directly specified domain name -use_domain = "header"; - -# Domain to use for DKIM signing when sender is in sign_networks ("header"/"envelope"/"auth") -#use_domain_sign_networks = "header"; - -# Domain to use for DKIM signing when sender is a local IP ("header"/"envelope"/"auth") -#use_domain_sign_local = "header"; - -# Whether to normalise domains to eSLD -use_esld = true; - -# Whether to get keys from Redis -use_redis = false; - -# Hash for DKIM keys in Redis -key_prefix = "DKIM_KEYS"; - -# map of domains -> names of selectors (since rspamd 1.5.3) -#selector_map = "/etc/rspamd/dkim_selectors.map"; - -# map of domains -> paths to keys (since rspamd 1.5.3) -#path_map = "/etc/rspamd/dkim_paths.map"; - -# If `true` get pubkey from DNS record and check if it matches private key -check_pubkey = false; -# Set to `false` if you want to skip signing if public and private keys mismatch -allow_pubkey_mismatch = true; - -# Domain specific settings -domain { - # Domain name is used as key - c3lf.de { - - # Private key path - path = "/var/lib/rspamd/dkim/{{ mail_domain }}.key"; - - # Selector - selector = "{{ mail_domain }}"; - } -} - diff --git a/deploy/dev/Dockerfile.backend b/deploy/dev/Dockerfile.backend deleted file mode 100644 index 19c2efd..0000000 --- a/deploy/dev/Dockerfile.backend +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.11-bookworm -LABEL authors="lagertonne" - -ENV PYTHONUNBUFFERED 1 -RUN mkdir /code -WORKDIR /code -COPY requirements.dev.txt /code/ -COPY requirements.prod.txt /code/ -RUN apt update && apt install -y mariadb-client -RUN pip install -r requirements.dev.txt -RUN pip install -r requirements.prod.txt -RUN pip install mysqlclient -COPY .. /code/ \ No newline at end of file diff --git a/deploy/dev/Dockerfile.frontend b/deploy/dev/Dockerfile.frontend deleted file mode 100644 index 0a41d1a..0000000 --- a/deploy/dev/Dockerfile.frontend +++ /dev/null @@ -1,6 +0,0 @@ -FROM docker.io/node:22 - -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 deleted file mode 100644 index e44c276..0000000 --- a/deploy/dev/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -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=dev.db - volumes: - - ../../core:/code - - ../testdata.py:/code/testdata.py - ports: - - "8000:8000" - - frontend: - build: - context: ../../web - dockerfile: ../deploy/dev/Dockerfile.frontend - command: npm run serve - volumes: - - ../../web:/web:ro - - /web/node_modules - - ./vue.config.js:/web/vue.config.js - ports: - - "8080:8080" - depends_on: - - core diff --git a/deploy/dev/vue.config.js b/deploy/dev/vue.config.js deleted file mode 100644 index f8f3c26..0000000 --- a/deploy/dev/vue.config.js +++ /dev/null @@ -1,27 +0,0 @@ -// 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 deleted file mode 100644 index dca385f..0000000 --- a/deploy/testdata.py +++ /dev/null @@ -1,88 +0,0 @@ -import os - - -def setup(): - from authentication.models import ExtendedUser, EventPermission - from inventory.models import Event - from django.contrib.auth.models import Permission, Group - permissions = ['add_item', 'view_item', 'view_file', 'delete_item', 'change_item'] - if not ExtendedUser.objects.filter(username='admin').exists(): - admin = ExtendedUser.objects.create_superuser('admin', 'admin@example.com', 'admin') - admin.set_password('admin') - admin.user_permissions.add(*Permission.objects.all()) - admin.save() - - if not ExtendedUser.objects.filter(username='testuser').exists(): - testuser = ExtendedUser.objects.create_user('testuser', 'testuser@example.com', 'testuser') - testuser.set_password('testuser') - testuser.user_permissions.add(*Permission.objects.all()) - testuser.save() - - team = Group.objects.get(name='Team') - team.permissions.add( - *Permission.objects.all() - ) - - if not ExtendedUser.objects.filter(username='testuser2').exists(): - testuser2 = ExtendedUser.objects.create_user('testuser2', 'testuser2@example.com', 'testuser2') - testuser2.set_password('testuser2') - testuser2.groups.add(team) - testuser2.save() - - event1 = Event.objects.get_or_create(id=1, name='first test event', slug='TEST1', - start='2023-12-18 00:00:00.000000', end='2023-12-27 00:00:00.000000', - pre_start='2023-12-31 00:00:00.000000', post_end='2024-01-04 00:00:00.000000')[ - 0] - - event2 = Event.objects.get_or_create(id=2, name='second test event', slug='TEST2', - start='2024-12-18 00:00:00.000000', end='2024-12-27 00:00:00.000000', - pre_start='2024-12-31 00:00:00.000000', post_end='2025-01-04 00:00:00.000000')[ - 0] - - # for permission in permissions: - # EventPermission.objects.create(event=event_37c3, user=foo, - # permission=Permission.objects.get(codename=permission)) - - from tickets.models import IssueThread - - from mail.models import Email - - issue_thread = IssueThread.objects.get_or_create( - id=1, - name="test", - event=Event.objects.get(slug='TEST1') - )[0] - mail1 = Email.objects.get_or_create( - id=1, - subject='test subject', - body='test', - sender='test1@test', - recipient='test2@test', - issue_thread=issue_thread, - )[0] - mail1_reply = Email.objects.get_or_create( - id=2, - subject='Message received', - body='Thank you for your message.', - sender='test2@test', - recipient='test1@test', - in_reply_to=mail1.reference, - issue_thread=issue_thread, - )[0] - - -def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") - import django - - django.setup() - - from django.core.management import call_command - call_command('migrate') - - setup() - print('testdata initialised') - - -if __name__ == '__main__': - main() diff --git a/deploy/testing/Dockerfile.backend b/deploy/testing/Dockerfile.backend deleted file mode 100644 index c968994..0000000 --- a/deploy/testing/Dockerfile.backend +++ /dev/null @@ -1,11 +0,0 @@ -FROM python:3.11-bookworm -LABEL authors="lagertonne" - -ENV PYTHONUNBUFFERED 1 -RUN mkdir /code -WORKDIR /code -COPY requirements.prod.txt /code/ -RUN apt update && apt install -y mariadb-client -RUN pip install -r requirements.prod.txt -RUN pip install mysqlclient -COPY .. /code/ \ No newline at end of file diff --git a/deploy/testing/Dockerfile.frontend b/deploy/testing/Dockerfile.frontend deleted file mode 100644 index 0a41d1a..0000000 --- a/deploy/testing/Dockerfile.frontend +++ /dev/null @@ -1,6 +0,0 @@ -FROM docker.io/node:22 - -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 deleted file mode 100644 index b41dd63..0000000 --- a/deploy/testing/docker-compose.yml +++ /dev/null @@ -1,72 +0,0 @@ -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 - - ../testdata.py:/code/testdata.py - 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 - - /web/node_modules - - ./vue.config.js:/web/vue.config.js - ports: - - "8080:8080" - depends_on: - - core - - mail: - image: docker.io/axllent/mailpit - volumes: - - mailpit_data:/data - ports: - - 8025:8025 - - 1025:1025 - environment: - MP_MAX_MESSAGES: 5000 - MP_DATABASE: /data/mailpit.db - MP_SMTP_AUTH_ACCEPT_ANY: 1 - MP_SMTP_AUTH_ALLOW_INSECURE: 1 - -volumes: - mariadb_data: - mailpit_data: diff --git a/deploy/testing/vue.config.js b/deploy/testing/vue.config.js deleted file mode 100644 index f8f3c26..0000000 --- a/deploy/testing/vue.config.js +++ /dev/null @@ -1,27 +0,0 @@ -// 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 new file mode 100644 index 0000000..567999f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..50df40c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + ./tests + + + + + ./app + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b75525b --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,21 @@ + + + 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 new file mode 100644 index 0000000..04aa086 --- /dev/null +++ b/public/index.php @@ -0,0 +1,28 @@ +run(); diff --git a/public/thumbnail.php b/public/thumbnail.php new file mode 100644 index 0000000..a6339a4 --- /dev/null +++ b/public/thumbnail.php @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..5de300b --- /dev/null +++ b/readme.md @@ -0,0 +1,9 @@ +# lfbackend + +This is the background api of c3lf.de + +### Development server +```bash +cp .env.example .env +docker-compose up +``` diff --git a/core/authentication/tests/__init__.py b/resources/views/.gitkeep similarity index 100% rename from core/authentication/tests/__init__.py rename to resources/views/.gitkeep diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..5d32e71 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 0000000..01e4a6c --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,3 @@ +* +!data/ +!.gitignore diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/cache/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/ApiTest.php b/tests/ApiTest.php new file mode 100644 index 0000000..76125a0 --- /dev/null +++ b/tests/ApiTest.php @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..529bf8f --- /dev/null +++ b/tests/ContainerTest.php @@ -0,0 +1,116 @@ +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 new file mode 100644 index 0000000..55c5259 --- /dev/null +++ b/tests/EventTest.php @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..0844897 --- /dev/null +++ b/tests/FileTest.php @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..163462f --- /dev/null +++ b/tests/ItemTest.php @@ -0,0 +1,173 @@ +'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 new file mode 100644 index 0000000..b96aac8 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,18 @@ +//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 deleted file mode 100644 index a58ee1b..0000000 --- a/web/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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 deleted file mode 100644 index c5dc6a5..0000000 --- a/web/babel.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - presets: [ - '@vue/cli-plugin-babel/preset' - ] -}; diff --git a/web/node_modules/.forgit_fordocker b/web/node_modules/.forgit_fordocker deleted file mode 100644 index e69de29..0000000 diff --git a/web/package.json b/web/package.json deleted file mode 100644 index 87f6d71..0000000 --- a/web/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "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 deleted file mode 100644 index f693383..0000000 Binary files a/web/public/favicon.ico and /dev/null differ diff --git a/web/public/index.html b/web/public/index.html deleted file mode 100644 index 313803b..0000000 --- a/web/public/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - c3lf - - - -
- - - diff --git a/web/src/App.vue b/web/src/App.vue deleted file mode 100644 index d2c9f7d..0000000 --- a/web/src/App.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - diff --git a/web/src/assets/logo.png b/web/src/assets/logo.png deleted file mode 100644 index 5287100..0000000 Binary files a/web/src/assets/logo.png and /dev/null differ diff --git a/web/src/components/AddBoxModal.vue b/web/src/components/AddBoxModal.vue deleted file mode 100644 index 228f117..0000000 --- a/web/src/components/AddBoxModal.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/AddEventModal.vue b/web/src/components/AddEventModal.vue deleted file mode 100644 index ed25265..0000000 --- a/web/src/components/AddEventModal.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/AddItemModal.vue b/web/src/components/AddItemModal.vue deleted file mode 100644 index 24bd449..0000000 --- a/web/src/components/AddItemModal.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/AddTicketModal.vue b/web/src/components/AddTicketModal.vue deleted file mode 100644 index b407670..0000000 --- a/web/src/components/AddTicketModal.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/AsyncLoader.vue b/web/src/components/AsyncLoader.vue deleted file mode 100644 index 00bf841..0000000 --- a/web/src/components/AsyncLoader.vue +++ /dev/null @@ -1,133 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/AuthenticatedDataLink.vue b/web/src/components/AuthenticatedDataLink.vue deleted file mode 100644 index f121af6..0000000 --- a/web/src/components/AuthenticatedDataLink.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/AuthenticatedImage.vue b/web/src/components/AuthenticatedImage.vue deleted file mode 100644 index 8b463b0..0000000 --- a/web/src/components/AuthenticatedImage.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Cards.vue b/web/src/components/Cards.vue deleted file mode 100644 index 99ea98e..0000000 --- a/web/src/components/Cards.vue +++ /dev/null @@ -1,74 +0,0 @@ - - - diff --git a/web/src/components/CollapsableCards.vue b/web/src/components/CollapsableCards.vue deleted file mode 100644 index d38206a..0000000 --- a/web/src/components/CollapsableCards.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - diff --git a/web/src/components/ExpandableTable.vue b/web/src/components/ExpandableTable.vue deleted file mode 100644 index 6bd5175..0000000 --- a/web/src/components/ExpandableTable.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Lightbox.vue b/web/src/components/Lightbox.vue deleted file mode 100644 index 74e878d..0000000 --- a/web/src/components/Lightbox.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Matrix2D.vue b/web/src/components/Matrix2D.vue deleted file mode 100644 index 17e4b0c..0000000 --- a/web/src/components/Matrix2D.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Matrix3D.vue b/web/src/components/Matrix3D.vue deleted file mode 100644 index 7a4d7f9..0000000 --- a/web/src/components/Matrix3D.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Modal.vue b/web/src/components/Modal.vue deleted file mode 100644 index c2c8f0b..0000000 --- a/web/src/components/Modal.vue +++ /dev/null @@ -1,75 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue deleted file mode 100644 index 7f5e257..0000000 --- a/web/src/components/Navbar.vue +++ /dev/null @@ -1,160 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Table.vue b/web/src/components/Table.vue deleted file mode 100644 index b9e3619..0000000 --- a/web/src/components/Table.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/Timeline.vue b/web/src/components/Timeline.vue deleted file mode 100644 index 41e1274..0000000 --- a/web/src/components/Timeline.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineAssignment.vue b/web/src/components/TimelineAssignment.vue deleted file mode 100644 index d384825..0000000 --- a/web/src/components/TimelineAssignment.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineComment.vue b/web/src/components/TimelineComment.vue deleted file mode 100644 index cf4bfb1..0000000 --- a/web/src/components/TimelineComment.vue +++ /dev/null @@ -1,86 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineMail.vue b/web/src/components/TimelineMail.vue deleted file mode 100644 index 5a76d95..0000000 --- a/web/src/components/TimelineMail.vue +++ /dev/null @@ -1,203 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelinePlacement.vue b/web/src/components/TimelinePlacement.vue deleted file mode 100644 index 220be01..0000000 --- a/web/src/components/TimelinePlacement.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineRelatedItem.vue b/web/src/components/TimelineRelatedItem.vue deleted file mode 100644 index 2670215..0000000 --- a/web/src/components/TimelineRelatedItem.vue +++ /dev/null @@ -1,208 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineRelatedTicket.vue b/web/src/components/TimelineRelatedTicket.vue deleted file mode 100644 index 694a4e0..0000000 --- a/web/src/components/TimelineRelatedTicket.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineShippingVoucher.vue b/web/src/components/TimelineShippingVoucher.vue deleted file mode 100644 index fb7c575..0000000 --- a/web/src/components/TimelineShippingVoucher.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/TimelineStateChange.vue b/web/src/components/TimelineStateChange.vue deleted file mode 100644 index d771b9e..0000000 --- a/web/src/components/TimelineStateChange.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/inputs/Addon.vue b/web/src/components/inputs/Addon.vue deleted file mode 100644 index 0a1c5b6..0000000 --- a/web/src/components/inputs/Addon.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/web/src/components/inputs/AsyncButton.vue b/web/src/components/inputs/AsyncButton.vue deleted file mode 100644 index 833f964..0000000 --- a/web/src/components/inputs/AsyncButton.vue +++ /dev/null @@ -1,46 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/inputs/ClipboardButton.vue b/web/src/components/inputs/ClipboardButton.vue deleted file mode 100644 index 0f164e6..0000000 --- a/web/src/components/inputs/ClipboardButton.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/web/src/components/inputs/InputCombo.vue b/web/src/components/inputs/InputCombo.vue deleted file mode 100644 index 2a291e0..0000000 --- a/web/src/components/inputs/InputCombo.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/web/src/components/inputs/InputPhoto.vue b/web/src/components/inputs/InputPhoto.vue deleted file mode 100644 index 90745a4..0000000 --- a/web/src/components/inputs/InputPhoto.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/inputs/InputString.vue b/web/src/components/inputs/InputString.vue deleted file mode 100644 index 9586d78..0000000 --- a/web/src/components/inputs/InputString.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/components/inputs/SearchBox.vue b/web/src/components/inputs/SearchBox.vue deleted file mode 100644 index 79fb798..0000000 --- a/web/src/components/inputs/SearchBox.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/main.js b/web/src/main.js deleted file mode 100644 index f6fe706..0000000 --- a/web/src/main.js +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 2077540..0000000 --- a/web/src/mixins/data-container.js +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 14f5577..0000000 --- a/web/src/persistent-state-plugin/index.js +++ /dev/null @@ -1,74 +0,0 @@ -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 deleted file mode 100644 index 03c1c14..0000000 --- a/web/src/router.js +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index f74958c..0000000 --- a/web/src/scss/navbar.scss +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 67397d0..0000000 --- a/web/src/shared-state-plugin/index.js +++ /dev/null @@ -1,345 +0,0 @@ -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 deleted file mode 100644 index 34650e4..0000000 --- a/web/src/store.js +++ /dev/null @@ -1,619 +0,0 @@ -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 deleted file mode 100644 index d753623..0000000 --- a/web/src/utils.js +++ /dev/null @@ -1,138 +0,0 @@ -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 deleted file mode 100644 index 5de5b26..0000000 --- a/web/src/views/Boxes.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/Empty.vue b/web/src/views/Empty.vue deleted file mode 100644 index ef221da..0000000 --- a/web/src/views/Empty.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/web/src/views/Error.vue b/web/src/views/Error.vue deleted file mode 100644 index 73e63e5..0000000 --- a/web/src/views/Error.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/web/src/views/Files.vue b/web/src/views/Files.vue deleted file mode 100644 index aac8c7e..0000000 --- a/web/src/views/Files.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/HowTo.vue b/web/src/views/HowTo.vue deleted file mode 100644 index 8e4526d..0000000 --- a/web/src/views/HowTo.vue +++ /dev/null @@ -1,141 +0,0 @@ - - - - - diff --git a/web/src/views/Item.vue b/web/src/views/Item.vue deleted file mode 100644 index 7b9f484..0000000 --- a/web/src/views/Item.vue +++ /dev/null @@ -1,184 +0,0 @@ - - - - - - diff --git a/web/src/views/ItemSearch.vue b/web/src/views/ItemSearch.vue deleted file mode 100644 index 8e9dcc4..0000000 --- a/web/src/views/ItemSearch.vue +++ /dev/null @@ -1,114 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/Items.vue b/web/src/views/Items.vue deleted file mode 100644 index 72b4079..0000000 --- a/web/src/views/Items.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue deleted file mode 100644 index d002cc6..0000000 --- a/web/src/views/Login.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/Register.vue b/web/src/views/Register.vue deleted file mode 100644 index 15412f7..0000000 --- a/web/src/views/Register.vue +++ /dev/null @@ -1,169 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/Ticket.vue b/web/src/views/Ticket.vue deleted file mode 100644 index 092bc68..0000000 --- a/web/src/views/Ticket.vue +++ /dev/null @@ -1,223 +0,0 @@ - - - - - diff --git a/web/src/views/TicketSearch.vue b/web/src/views/TicketSearch.vue deleted file mode 100644 index f5a4a7d..0000000 --- a/web/src/views/TicketSearch.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/Tickets.vue b/web/src/views/Tickets.vue deleted file mode 100644 index 60d0d1d..0000000 --- a/web/src/views/Tickets.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/admin/AccessControl.vue b/web/src/views/admin/AccessControl.vue deleted file mode 100644 index 339e35a..0000000 --- a/web/src/views/admin/AccessControl.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/admin/Admin.vue b/web/src/views/admin/Admin.vue deleted file mode 100644 index 5a71223..0000000 --- a/web/src/views/admin/Admin.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/admin/Boxes.vue b/web/src/views/admin/Boxes.vue deleted file mode 100644 index 3355b0c..0000000 --- a/web/src/views/admin/Boxes.vue +++ /dev/null @@ -1,47 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/admin/Dashboard.vue b/web/src/views/admin/Dashboard.vue deleted file mode 100644 index 6272d71..0000000 --- a/web/src/views/admin/Dashboard.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/admin/Events.vue b/web/src/views/admin/Events.vue deleted file mode 100644 index e156d56..0000000 --- a/web/src/views/admin/Events.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/views/admin/Shipping.vue b/web/src/views/admin/Shipping.vue deleted file mode 100644 index 4ba1ed3..0000000 --- a/web/src/views/admin/Shipping.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/vue.config.js b/web/vue.config.js deleted file mode 100644 index 6a50fe3..0000000 --- a/web/vue.config.js +++ /dev/null @@ -1,31 +0,0 @@ -// 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