From 90b7d34c69bf49b60c93c6c8ae8c8a2e2a3ddf9f Mon Sep 17 00:00:00 2001
From: Marc Leuser <marcquark@users.noreply.github.com>
Date: Tue, 30 Mar 2021 04:41:26 +0200
Subject: [PATCH] Added #6695: add API endpoint for license seats (#8058)

* remove miselading comment line

* added dedicated API endpoint for license seats

* don't display a seat name via API
it makes no sense and we don't have any particular sorting order
so the numbering would be inconsistent anyway

* reduce amount of IFs

* add sanity checks to show()

* fix goofed logging logic

* add tests for action log entries
---
 .../Api/LicenseSeatsController.php            | 138 +++++++++++++
 .../Controllers/Api/LicensesController.php    |  44 -----
 .../Transformers/LicenseSeatsTransformer.php  |   7 +-
 app/Models/LicenseSeat.php                    |  10 +
 resources/views/licenses/view.blade.php       |   2 +-
 routes/api.php                                |  19 +-
 tests/api/ApiLicenseSeatsCest.php             | 185 ++++++++++++++++++
 7 files changed, 351 insertions(+), 54 deletions(-)
 create mode 100644 app/Http/Controllers/Api/LicenseSeatsController.php
 create mode 100644 tests/api/ApiLicenseSeatsCest.php

diff --git a/app/Http/Controllers/Api/LicenseSeatsController.php b/app/Http/Controllers/Api/LicenseSeatsController.php
new file mode 100644
index 000000000..05cdd9895
--- /dev/null
+++ b/app/Http/Controllers/Api/LicenseSeatsController.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace App\Http\Controllers\Api;
+
+use App\Helpers\Helper;
+use App\Http\Controllers\Controller;
+use App\Http\Transformers\LicenseSeatsTransformer;
+use App\Models\Asset;
+use App\Models\License;
+use App\Models\LicenseSeat;
+use App\Models\User;
+use Auth;
+use Illuminate\Http\Request;
+
+class LicenseSeatsController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request, $licenseId)
+    {
+        //
+        if ($license = License::find($licenseId)) {
+            $this->authorize('view', $license);
+
+            $seats = LicenseSeat::with('license', 'user', 'asset', 'user.department')
+                ->where('license_seats.license_id', $licenseId);
+
+            $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
+
+            if ($request->input('sort')=='department') {
+                $seats->OrderDepartments($order);
+            } else {
+                $seats->orderBy('id', $order);
+            }
+
+            $total = $seats->count();
+            $offset = (($seats) && (request('offset') > $total)) ? 0 : request('offset', 0);
+            $limit = request('limit', 50);
+            
+            $seats = $seats->skip($offset)->take($limit)->get();
+
+            if ($seats) {
+                return (new LicenseSeatsTransformer)->transformLicenseSeats($seats, $total);
+            }
+        }
+
+        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.does_not_exist')), 200);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($licenseId, $seatId)
+    {
+        //
+        $this->authorize('view', License::class);
+        // sanity checks:
+        // 1. does the license seat exist?
+        if (!$licenseSeat = LicenseSeat::find($seatId)) {
+            return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
+        }
+        // 2. does the seat belong to the specified license?
+        if (!$license = $licenseSeat->license()->first() || $license->id != intval($licenseId)) {
+            return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
+        }
+        return (new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $licenseId
+     * @param  int  $seatId
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $licenseId, $seatId)
+    {
+        $this->authorize('checkout', License::class);
+
+        // sanity checks:
+        // 1. does the license seat exist?
+        if (!$licenseSeat = LicenseSeat::find($seatId)) {
+            return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat not found'));
+        }
+        // 2. does the seat belong to the specified license?
+        if (!$license = $licenseSeat->license()->first() || $license->id != intval($licenseId)) {
+            return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
+        }
+
+        $oldUser = $licenseSeat->user()->first();
+        $oldAsset = $licenseSeat->asset()->first();
+
+        // attempt to update the license seat
+        $licenseSeat->fill($request->all());
+        $licenseSeat->user_id = Auth::user()->id;
+        
+        // check if this update is a checkin operation
+        // 1. are relevant fields touched at all?
+        $touched = $licenseSeat->isDirty('assigned_to') || $licenseSeat->isDirty('asset_id');
+        // 2. are they cleared? if yes then this is a checkin operation
+        $is_checkin = ($touched && $licenseSeat->assigned_to === null && $licenseSeat->asset_id === null);
+
+        if (!$touched) {
+            // nothing to update
+            return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
+        }
+
+        if ($licenseSeat->save()) {
+            // the logging functions expect only one "target". if both asset and user are present in the request,
+            // we simply let assets take precedence over users...
+            $changes = $licenseSeat->getChanges();
+            if (array_key_exists('assigned_to', $changes)) {
+                $target = $is_checkin ? $oldUser : User::find($changes['assigned_to']);
+            }
+            if (array_key_exists('asset_id', $changes)) {
+                $target = $is_checkin ? $oldAsset : Asset::find($changes['asset_id']);
+            }
+
+            if ($is_checkin) {
+                $licenseSeat->logCheckin($target, $request->input('note'));
+                return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
+            }
+
+            // in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation.
+            $licenseSeat->logCheckout($request->input('note'), $target);
+            return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
+        }
+
+        return Helper::formatStandardApiResponse('error', null, $licenseSeat->getErrors());
+    }
+}
diff --git a/app/Http/Controllers/Api/LicensesController.php b/app/Http/Controllers/Api/LicensesController.php
index 07bacdc4d..268248ab7 100644
--- a/app/Http/Controllers/Api/LicensesController.php
+++ b/app/Http/Controllers/Api/LicensesController.php
@@ -237,50 +237,6 @@ class LicensesController extends Controller
         }
         return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.assoc_users')));
     }
-
-    /**
-     * Get license seat listing
-     *
-     * @author [A. Gianotto] [<snipe@snipe.net>]
-     * @since [v1.0]
-     * @param int $licenseId
-     * @return \Illuminate\Contracts\View\View
-     */
-    public function seats(Request $request, $licenseId)
-    {
-
-        if ($license = License::find($licenseId)) {
-
-            $this->authorize('view', $license);
-
-            $seats = LicenseSeat::with('license', 'user', 'asset', 'user.department')
-                ->where('license_seats.license_id', $licenseId);
-
-            $order = $request->input('order') === 'asc' ? 'asc' : 'desc';
-
-            if ($request->input('sort')=='department') {
-                $seats->OrderDepartments($order);
-            } else {
-                $seats->orderBy('id', $order);
-            }
-
-            $offset = (($seats) && (request('offset') > $seats->count())) ? 0 : request('offset', 0);
-            $limit = request('limit', 50);
-            
-            $total = $seats->count();
-
-            $seats = $seats->skip($offset)->take($limit)->get();
-
-            if ($seats) {
-                return (new LicenseSeatsTransformer)->transformLicenseSeats($seats, $total);
-            }
-
-        }
-
-        return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.does_not_exist')), 200);
-
-    }
-
     
     /**
      * Gets a paginated collection for the select2 menus
diff --git a/app/Http/Transformers/LicenseSeatsTransformer.php b/app/Http/Transformers/LicenseSeatsTransformer.php
index f7685db16..c8454d06e 100644
--- a/app/Http/Transformers/LicenseSeatsTransformer.php
+++ b/app/Http/Transformers/LicenseSeatsTransformer.php
@@ -20,12 +20,11 @@ class LicenseSeatsTransformer
         return (new DatatablesTransformer)->transformDatatables($array, $total);
     }
 
-    public function transformLicenseSeat (LicenseSeat $seat, $seat_count)
+    public function transformLicenseSeat (LicenseSeat $seat, $seat_count=0)
     {
         $array = [
             'id' => (int) $seat->id,
             'license_id' => (int) $seat->license->id,
-            'name' => 'Seat '.$seat_count,
             'assigned_user' => ($seat->user) ? [
                 'id' => (int) $seat->user->id,
                 'name'=> e($seat->user->present()->fullName),
@@ -49,6 +48,10 @@ class LicenseSeatsTransformer
             'user_can_checkout' => (($seat->assigned_to=='') && ($seat->asset_id=='')),
         ];
 
+        if($seat_count != 0) {
+            $array['name'] = 'Seat '.$seat_count;
+        }
+
         $permissions_array['available_actions'] = [
             'checkout' => Gate::allows('checkout', License::class),
             'checkin' => Gate::allows('checkin', License::class),
diff --git a/app/Models/LicenseSeat.php b/app/Models/LicenseSeat.php
index 590409f77..40a53adf8 100755
--- a/app/Models/LicenseSeat.php
+++ b/app/Models/LicenseSeat.php
@@ -20,6 +20,16 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild
     protected $guarded = 'id';
     protected $table = 'license_seats';
 
+    /**
+    * The attributes that are mass assignable.
+    *
+    * @var array
+    */
+    protected $fillable = [
+        'assigned_to',
+        'asset_id'
+    ];
+
     use Acceptable;
 
     public function getCompanyableParents()
diff --git a/resources/views/licenses/view.blade.php b/resources/views/licenses/view.blade.php
index a578efc0a..80de54d95 100755
--- a/resources/views/licenses/view.blade.php
+++ b/resources/views/licenses/view.blade.php
@@ -350,7 +350,7 @@
                         data-sort-order="asc"
                         data-sort-name="name"
                         class="table table-striped snipe-table"
-                        data-url="{{ route('api.license.seats', $license->id) }}"
+                        data-url="{{ route('api.licenses.seats.index', $license->id) }}"
                         data-export-options='{
                         "fileName": "export-seats-{{ str_slug($license->name) }}-{{ date('Y-m-d') }}",
                         "ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
diff --git a/routes/api.php b/routes/api.php
index 2c6a6ab26..c8f05d2f1 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -166,7 +166,6 @@ Route::group(['prefix' => 'v1','namespace' => 'Api', 'middleware' => 'auth:api']
 
     /*--- Departments API ---*/
 
-    /*--- Suppliers API ---*/
     Route::group(['prefix' => 'departments'], function () {
 
 
@@ -496,11 +495,6 @@ Route::group(['prefix' => 'v1','namespace' => 'Api', 'middleware' => 'auth:api']
     /*--- Licenses API ---*/
 
     Route::group(['prefix' => 'licenses'], function () {
-        Route::get('{licenseId}/seats', [
-            'as' => 'api.license.seats',
-            'uses' => 'LicensesController@seats'
-        ]);
-        
         Route::get('selectlist',
             [
                 'as' => 'api.licenses.selectlist',
@@ -525,7 +519,18 @@ Route::group(['prefix' => 'v1','namespace' => 'Api', 'middleware' => 'auth:api']
         ]
     ); // Licenses resource
 
-
+    Route::resource('licenses.seats', 'LicenseSeatsController',
+        [
+            'names' =>
+                [
+                    'index' => 'api.licenses.seats.index',
+                    'show' => 'api.licenses.seats.show',
+                    'update' => 'api.licenses.seats.update'
+                ],
+            'except' => ['create', 'edit', 'destroy', 'store'],
+            'parameters' => ['licenseseat' => 'licenseseat_id']
+        ]
+    ); // Licenseseats resource
 
     /*--- Locations API ---*/
 
diff --git a/tests/api/ApiLicenseSeatsCest.php b/tests/api/ApiLicenseSeatsCest.php
new file mode 100644
index 000000000..30c9ccaa9
--- /dev/null
+++ b/tests/api/ApiLicenseSeatsCest.php
@@ -0,0 +1,185 @@
+<?php
+
+use App\Http\Transformers\LicenseSeatsTransformer;
+use App\Models\Asset;
+use App\Models\License;
+use App\Models\LicenseSeat;
+use App\Models\User;
+
+class ApiLicenseSeatsCest
+{
+    protected $license;
+    protected $timeFormat;
+
+    public function _before(ApiTester $I)
+    {
+        $this->user = \App\Models\User::find(1);
+        $I->haveHttpHeader('Accept', 'application/json');
+        $I->amBearerAuthenticated($I->getToken($this->user));
+    }
+
+    /** @test */
+    public function indexLicenseSeats(ApiTester $I)
+    {
+        $I->wantTo('Get a list of license seats for a specific license');
+
+        // call
+        $I->sendGET('/licenses/1/seats?limit=10&order=desc');
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+
+        // sample verify
+        $licenseSeats = App\Models\LicenseSeat::where('license_id', 1)
+            ->orderBy('id','desc')->take(10)->get();
+        // pick a random seat
+        $licenseSeat = $licenseSeats->random();
+        // need the index in the original list so that the "name" field is determined correctly
+        $licenseSeatNumber = 0;
+        foreach($licenseSeats as $index=>$seat) {
+            if ($licenseSeat === $seat) {
+                $licenseSeatNumber = $index+1;
+            }
+        }
+        $I->seeResponseContainsJson($I->removeTimestamps((new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat, $licenseSeatNumber)));
+    }
+
+    /** @test */
+    public function showLicenseSeat(ApiTester $I)
+    {
+        $I->wantTo('Get a license seat');
+
+        // call
+        $I->sendGET('/licenses/1/seats/10');
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+
+        // sample verify
+        $licenseSeat = App\Models\LicenseSeat::findOrFail(10);
+        $I->seeResponseContainsJson($I->removeTimestamps((new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat)));
+    }
+
+    /** @test */
+    public function checkoutLicenseSeatToUser(ApiTester $I)
+    {
+        $I->wantTo('Checkout a license seat to a user');
+
+        $user = App\Models\User::all()->random();
+        $licenseSeat = App\Models\LicenseSeat::all()->random();
+        $endpoint = '/licenses/'.$licenseSeat->license_id.'/seats/'.$licenseSeat->id;
+
+        $data = [
+            'assigned_to' => $user->id,
+            'note' => 'Test Checkout to User via API'
+        ];
+
+        // update
+        $I->sendPATCH($endpoint, $data);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+
+        $response = json_decode($I->grabResponse());
+        $I->assertEquals('success', $response->status);
+        $I->assertEquals(trans('admin/licenses/message.update.success'), $response->messages);
+        $I->assertEquals($licenseSeat->license_id, $response->payload->license_id); // license id does not change
+        $I->assertEquals($licenseSeat->id, $response->payload->id); // license seat id does not change
+
+        // verify
+        $licenseSeat = $licenseSeat->fresh();
+        $I->sendGET($endpoint);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseContainsJson($I->removeTimestamps((new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat)));
+
+        // verify that the last logged action is a checkout
+        $I->sendGET('/reports/activity?item_type=license&limit=1&item_id='.$licenseSeat->license_id);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseContainsJson([
+            "action_type" => "checkout"
+        ]);
+    }
+
+    /** @test */
+    public function checkoutLicenseSeatToAsset(ApiTester $I)
+    {
+        $I->wantTo('Checkout a license seat to an asset');
+
+        $asset = App\Models\Asset::all()->random();
+        $licenseSeat = App\Models\LicenseSeat::all()->random();
+        $endpoint = '/licenses/'.$licenseSeat->license_id.'/seats/'.$licenseSeat->id;
+
+        $data = [
+            'asset_id' => $asset->id,
+            'note' => 'Test Checkout to Asset via API'
+        ];
+
+        // update
+        $I->sendPATCH($endpoint, $data);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+
+        $response = json_decode($I->grabResponse());
+        $I->assertEquals('success', $response->status);
+        $I->assertEquals(trans('admin/licenses/message.update.success'), $response->messages);
+        $I->assertEquals($licenseSeat->license_id, $response->payload->license_id); // license id does not change
+        $I->assertEquals($licenseSeat->id, $response->payload->id); // license seat id does not change
+
+        // verify
+        $licenseSeat = $licenseSeat->fresh();
+        $I->sendGET($endpoint);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseContainsJson($I->removeTimestamps((new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat)));
+
+        // verify that the last logged action is a checkout
+        $I->sendGET('/reports/activity?item_type=license&limit=1&item_id='.$licenseSeat->license_id);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseContainsJson([
+            "action_type" => "checkout"
+        ]);
+    }
+
+    /** @test */
+    public function checkoutLicenseSeatToUserAndAsset(ApiTester $I)
+    {
+        $I->wantTo('Checkout a license seat to a user AND an asset');
+
+        $asset = App\Models\Asset::all()->random();
+        $user = App\Models\User::all()->random();
+        $licenseSeat = App\Models\LicenseSeat::all()->random();
+        $endpoint = '/licenses/'.$licenseSeat->license_id.'/seats/'.$licenseSeat->id;
+
+        $data = [
+            'asset_id' => $asset->id,
+            'assigned_to' => $user->id,
+            'note' => 'Test Checkout to User and Asset via API'
+        ];
+
+        // update
+        $I->sendPATCH($endpoint, $data);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+
+        $response = json_decode($I->grabResponse());
+        $I->assertEquals('success', $response->status);
+        $I->assertEquals(trans('admin/licenses/message.update.success'), $response->messages);
+        $I->assertEquals($licenseSeat->license_id, $response->payload->license_id); // license id does not change
+        $I->assertEquals($licenseSeat->id, $response->payload->id); // license seat id does not change
+
+        // verify
+        $licenseSeat = $licenseSeat->fresh();
+        $I->sendGET($endpoint);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseContainsJson($I->removeTimestamps((new LicenseSeatsTransformer)->transformLicenseSeat($licenseSeat)));
+
+        // verify that the last logged action is a checkout
+        $I->sendGET('/reports/activity?item_type=license&limit=1&item_id='.$licenseSeat->license_id);
+        $I->seeResponseIsJson();
+        $I->seeResponseCodeIs(200);
+        $I->seeResponseContainsJson([
+            "action_type" => "checkout"
+        ]);
+    }
+}
-- 
GitLab