diff --git a/src/app/Http/Controllers/UserController.php b/src/app/Http/Controllers/UserController.php index 6894308..126d53e 100644 --- a/src/app/Http/Controllers/UserController.php +++ b/src/app/Http/Controllers/UserController.php @@ -2,29 +2,33 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; -use App\Http\Controllers\Controller; -use Illuminate\Support\Facades\Validator; -use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\Auth; -use App\Mail\SetUpPassword; -use App\User; -use App\PasswordReset; use App\Institution; +use App\Mail\SetUpPassword; use App\Organisation; -use App\Volunteer; +use App\Parser\CsvParser; +use App\PasswordReset; +use App\Services\User\Import; +use App\User; +use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; +use Illuminate\Log\Logger; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; class UserController extends Controller { /** * Function responsible of processing get all users requests. - * + * * @param object $request Contains all the data needed for extracting the users list. - * + * * @return object 200 and the list of users if successful * 500 if an error occurs - * + * * @SWG\Get( * tags={"Users"}, * path="/api/users", @@ -36,30 +40,31 @@ class UserController extends Controller * ) * */ - public function index(Request $request) { + public function index(Request $request) + { $params = $request->query(); $users = User::query(); - if(isRole('institution')) { + if (isRole('institution')) { $users->where('role', '=', '0')->where('institution._id', '=', getAffiliationId()); } - applyFilters($users, $params, array('0' => array( 'institution._id', 'ilike'), '1' => array( 'name', 'ilike' ),)); - applySort($users, $params, array('1' => 'name', '2' => 'role', '3' => 'institution.name')); + applyFilters($users, $params, ['0' => ['institution._id', 'ilike'], '1' => ['name', 'ilike'],]); + applySort($users, $params, ['1' => 'name', '2' => 'role', '3' => 'institution.name']); $pager = applyPaginate($users, $params); - return response()->json(array("pager" => $pager, "data" => $users->get()), 200); + return response()->json(["pager" => $pager, "data" => $users->get()], 200); } - /** + /** * Function responsible of extracting a user details requests. - * + * * @param object $request Contains all the data needed for extracting the user details. - * + * * @return object 200 and the JSON encoded user details if successful * 500 if an error occurs - * + * * @SWG\Get( * tags={"Users"}, * path="/api/users/{id}", @@ -71,15 +76,16 @@ public function index(Request $request) { * ) * */ - public function show($id) { + public function show($id) + { $user = User::findOrFail($id); allowResourceAccess($user); - if(isset($user->organisation['_id'])){ + if (isset($user->organisation['_id'])) { $user->organisation = Organisation::find($user->organisation['_id']); } - if(isset($user->institution['_id'])){ + if (isset($user->institution['_id'])) { $user->institution = Institution::find($user->institution['_id']); } @@ -89,13 +95,13 @@ public function show($id) { /** * Function responsible of processing user creation requests. - * + * * @param object $request Contains all the data needed for creating a new user. - * + * * @return object 201 and the JSON encoded new user details if successful * 400 if validation fails * 500 if an error occurs - * + * * @SWG\Post( * tags={"Users"}, * path="/api/users", @@ -149,7 +155,8 @@ public function show($id) { * ) * */ - public function store(Request $request) { + public function store(Request $request) + { $data = $request->all(); $rules = [ 'name' => 'required|string|max:255', @@ -164,35 +171,38 @@ public function store(Request $request) { } $data = convertData($validator->validated(), $rules); - if(!isRole('dsu')){ - if(isset($data['institution'])) { + if (!isRole('dsu')) { + if (isset($data['institution'])) { unset($data['institution']); } - if(isset($data['organisation'])){ + if (isset($data['organisation'])) { unset($data['organisation']); } } else { $request->has('institution') ? $institution = Institution::findOrFail($request->institution) : ''; - if(isset($institution)) { + if (isset($institution)) { $data['institution'] = ['_id' => $institution->_id, 'name' => $institution->name]; } $request->has('organisation') ? $organisation = Organisation::findOrFail($request->organisation) : ''; - if(isset($organisation)) { + if (isset($organisation)) { $data['organisation'] = ['_id' => $organisation->_id, 'name' => $organisation->name]; } } $data = setAffiliate($data); - if(\Auth::check()) { - $data['added_by'] = \Auth::user()->_id; + if (\Auth::check()) { + $data['added_by'] = \Auth::user()->_id; } /** Generate a password reset. */ - $passwordReset = PasswordReset::updateOrCreate(['email' => $data['email']], ['email' => $data['email'], 'token' => str_random(60)]); + $passwordReset = PasswordReset::updateOrCreate( + ['email' => $data['email']], + ['email' => $data['email'], 'token' => str_random(60)] + ); /** Create the pass reset URL. */ - $url = env('FRONT_END_URL') . '/auth/reset/' . $passwordReset->token; - $set_password_data = array('url' => $url); + $url = env('FRONT_END_URL').'/auth/reset/'.$passwordReset->token; + $set_password_data = ['url' => $url]; /** Send welcoming email. */ Mail::to($data['email'])->send(new SetUpPassword($set_password_data)); @@ -202,10 +212,15 @@ public function store(Request $request) { $user = User::create($data); /** Chech if the user is ngo-admin. */ - if($user['role'] == 2) { + if ($user['role'] == 2) { /** Extract the organization and update the contact person. */ $organisation = Organisation::query()->where('_id', '=', $user['organisation._id'])->first(); - $organisation->contact_person = (object) ['_id'=>$user['_id'], 'name'=>$user['name'], 'email'=>$user['email'], 'phone'=>$user['phone']]; + $organisation->contact_person = (object)[ + '_id' => $user['_id'], + 'name' => $user['name'], + 'email' => $user['email'], + 'phone' => $user['phone'], + ]; $organisation->save(); } $response = ["message" => 'Password sent to email.', "user" => $user]; @@ -216,14 +231,14 @@ public function store(Request $request) { /** * Function responsible of processing user update requests. - * + * * @param object $request Contains all the data needed for updating a user. * @param string $id The ID of the user to be updated. - * + * * @return object 201 and the JSON encoded user details if successful * 400 if validation fails * 500 if an error occurs - * + * * @SWG\put( * tags={"Users"}, * path="/api/users/{id}", @@ -235,36 +250,37 @@ public function store(Request $request) { * ) * */ - public function update(Request $request, $id) { + public function update(Request $request, $id) + { $user = User::findOrFail($id); allowResourceAccess($user); $user = setAffiliate($user); $data = $request->all(); - if(isset($data['institution']) && $data['institution']) { + if (isset($data['institution']) && $data['institution']) { $institution = Institution::findOrFail($data['institution']); $data['institution'] = ['_id' => $institution->_id, 'name' => $institution->name]; } - if(isset($data['organisation']) && $data['organisation']) { + if (isset($data['organisation']) && $data['organisation']) { $organisation = Organisation::findOrFail($data['organisation']); $data['organisation'] = ['_id' => $organisation->_id, 'name' => $organisation->name]; } $user->update($data); - return response()->json($user, 201); + return response()->json($user, 201); } /** * Function responsible of processing delete user requests. - * + * * @param object $request Contains all the data needed for deleting a user. * @param string $id The ID of the user to be deleted. - * + * * @return object 200 if deletion is successful * 500 if an error occurs - * + * * @SWG\Delete( * tags={"Users"}, * path="/api/users/{id}", @@ -276,21 +292,134 @@ public function update(Request $request, $id) { * ) * */ - public function delete(Request $request, $id) { + public function delete(Request $request, $id) + { $user = User::findOrFail($id); allowResourceAccess($user); - if(!isRole('dsu') && !isRole('ngo') && getAffiliationId($id) != \Auth::user()->institution['_id']){ - isDenied(); + if (!isRole('dsu') && !isRole('ngo') && getAffiliationId($id) != \Auth::user()->institution['_id']) { + isDenied(); } - if($user->role == 2) { + if ($user->role == 2) { $ong = Organisation::query()->where('_id', '=', $user->organisation['_id'])->first(); - $ong->contact_person = (object) ['_id'=>null, 'name'=>null, 'email'=>null, 'phone'=>null]; + $ong->contact_person = (object)['_id' => null, 'name' => null, 'email' => null, 'phone' => null]; $ong->save(); } $user->delete(); - $response = array("message" => 'User deleted.'); + $response = ["message" => 'User deleted.']; return response()->json($response, 200); } + + /** + * Function responsible of processing import rescue officers requests. + * + * @param object $request Contains all the data needed for importing a list of users. + * + * @return object 200 if import is successful + * 500 if an error occurs + * + * @SWG\Post( + * tags={"Users"}, + * path="/api/users/import", + * summary="Import CSV with users", + * operationId="post", + * @SWG\Parameter( + * name="file", + * in="query", + * description="CSV file.", + * required=true, + * type="File" + * ), + * @SWG\Parameter( + * name="Coloanele din CSV", + * in="query", + * description="nume,email,judet,localitate,telefon, institutie,organizatie", + * required=false, + * type="string" + * ), + * @SWG\Response(response=200, description="successful operation"), + * @SWG\Response(response=400, description="request invalid"), + * @SWG\Response(response=406, description="not acceptable"), + * @SWG\Response(response=500, description="internal server error") + * ) + * + */ + public function importUsers(Request $request, Import $import): \Illuminate\Http\JsonResponse + { + + /** @var User $authenticatedUser */ + $authenticatedUser = \Auth::check() ? \Auth::user() : isDenied();; + + $file = $request->file('file'); + + if (!$file instanceof UploadedFile) { + return response()->json( + [ + "message" => 'Trebuie sa incarcati un fisier', + ], + 400 + ); + } + + if(!in_array($file->getClientOriginalExtension(), ['csv'] )){ + return response()->json( + [ + "message" => 'Fisierul trebuie sa fie de tip csv', + ], + 400 + ); + } + + $parser = new CsvParser($file->getPathname()); + + if (true === $import->importRescueOfficers($parser->parse(), $authenticatedUser)->hasErrors()) { + + if ($import->getTotalImported() === 0) { + $message = "Importul nu a putut fi efectuat"; + } else { + $message = "Import partial finalizat"; + } + + } else { + $message = "Import finalizat cu success"; + } + + $importedUsers = $import->getCreatedUsers(); + + App::terminating(function() use ($importedUsers){ + + foreach($importedUsers as $createdUser){ + + /** Generate a password reset. */ + $passwordReset = PasswordReset::updateOrCreate( + ['email' => $createdUser->email], + ['email' => $createdUser->email, 'token' => Str::random(60)] + ); + + /** Create the pass reset URL. */ + $url = env('FRONT_END_URL').'/auth/reset/'.$passwordReset->token; + + try { + /** Send welcoming email. */ + Mail::to($createdUser->email)->send(new SetUpPassword(['url' => $url])); + }catch (\Exception $e){ + //fail silently + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + } + } + + }); + + return response()->json( + [ + "has_errors" => $import->hasErrors(), + "total_errors" => count($import->getErrors()), + "rows_imported" => $import->getTotalImported(), + "errors" => $import->getErrors(), + "message" => $message, + ] + ); + } } diff --git a/src/app/Organisation.php b/src/app/Organisation.php index df2bf52..c9b9def 100644 --- a/src/app/Organisation.php +++ b/src/app/Organisation.php @@ -2,6 +2,7 @@ namespace App; +use Illuminate\Support\Str; use Robsonvn\CouchDB\Eloquent\Model as Eloquent; class Organisation extends Eloquent @@ -10,7 +11,12 @@ class Organisation extends Eloquent protected $collection = 'organisations'; protected $fillable = [ - 'name', 'website', 'contact_person', 'address', 'county', 'city', 'comments', 'added_by', 'cover' + 'name', 'website', 'contact_person', 'address', 'county', 'city', 'comments', 'added_by', 'cover', 'slug' ]; + public function setNameAttribute($value){ + $this->attributes['name'] = $value; + $this->attributes['slug'] = Str::slug($value); + } + } \ No newline at end of file diff --git a/src/app/Parser/CsvParser.php b/src/app/Parser/CsvParser.php new file mode 100644 index 0000000..12611c4 --- /dev/null +++ b/src/app/Parser/CsvParser.php @@ -0,0 +1,82 @@ +value] + * + * $hasHeaders MUST BE TRUE + * + * @var bool + */ + protected $includeHeadersInData = false; + + /** + * + * @param string $filePath + * @param boolean $hasHeaders + * @param bool|null $includeHeadersInData + */ + public function __construct(string $filePath, ?bool $hasHeaders = true, ?bool $includeHeadersInData = false) + { + $this->file = fopen($filePath, 'r'); + $this->hasHeaders = $hasHeaders; + } + + public function parse(): ?iterable + { + if ($this->hasHeaders) { + $headers = array_map('trim', (array)fgetcsv($this->file, 4096)); + } + + while (!feof($this->file)) { + $data = fgetcsv($this->file, 4096); + + if (false === $data) { + return; + } + + $row = array_map('trim', (array)$data); + + if ($this->hasHeaders && $this->includeHeadersInData) { + if (count($headers) !== count($row)) { + continue; + } + $row = array_combine($headers, $row); + } + + yield $row; + } + + $this->rewind(); + + return; + } + + public function rewind(): void + { + rewind($this->file); + } + + public function __destruct() + { + fclose($this->file); + } + +} diff --git a/src/app/Services/User/Import.php b/src/app/Services/User/Import.php new file mode 100644 index 0000000..f310ae1 --- /dev/null +++ b/src/app/Services/User/Import.php @@ -0,0 +1,277 @@ +setup(); + } + + public function setup() + { + $this->rolesList = array_values(config('roles.role')); + $this->prepareCountiesMap(); + } + + protected function prepareCountiesMap() + { + /** @var \Illuminate\Database\Eloquent\Collection $countys */ + $counties = County::all(['_id', "slug", "name"]); + $this->countiesMap = array_column($counties->toArray(), null, 'slug'); + } + + public function importRescueOfficers(iterable $list, User $importedBy) + { + return $this->import($list, User::ROLE_RESCUE_OFFICER, $importedBy); + } + + public function import(iterable $dataset, $userRole, User $importedBy): self + { + if (!in_array($userRole, $this->rolesList)) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid user type! Must be one of %s you provided %s', + print_r($this->rolesList, true), + $userRole + ) + ); + } + + + /* + * array(7) { + [0]=> "test popescu" + [1]=> "asfa@fas.asd" + [2]=> "Iasi" + [3]=> "Tg. frumos" + [4]=> "123123" + [5]=> "institutie slug" + [6]=> "organizatie slug" + } + */ + /** @var array $item */ + foreach ($dataset as $item) { + + $county = $this->getCounty($item[2]); + + $city = $this->getCity($item[3], $county); + + $userData = [ + 'name' => $item[0], + 'email' => $item[1], + 'county' => $county, + 'city' => $city, + 'password' => Hash::make(Str::random(16)), + 'role' => $userRole, + 'phone' => $item[4], + 'added_by' => $importedBy->_id, + ]; + + $institution = $this->getInstitution($item[5], $importedBy); + if ($institution) { + $userData['institution'] = $institution; + } + + $organisation = $this->getOrganisation($item[6], $importedBy); + if ($organisation) { + $userData['organisation'] = $organisation; + } + + if ($this->validateUser($userData)) { + /* + * putem face asta intr-un batch insert ? + */ + $user = User::create($userData); + + $this->usersCreated[] = $user; + } + + } + + return $this; + } + + protected function getCounty(string $slug): ?array + { + $countySlug = removeDiacritics($slug); + + if (!isset($this->countiesMap[$countySlug])) { + $this->errors = addError($this->errors, $slug, 'Judetul nu exista'); + + return null; + } + + return $this->countiesMap[$countySlug]; + } + + protected function getCity(string $citySlug, array $county): ?array + { + $citySlug = removeDiacritics($citySlug); + + if (isset($this->citiesMap[$citySlug])) { + return $this->citiesMap[$citySlug]; + } + + + try { + /** + * this should be cached + * - locally + * - memcached, redis + * -second level cache in ORM ? + */ + $getCity = \DB::connection('statics')->getCouchDBClient() + ->createViewQuery('cities', 'slug') + ->setKey([$county['_id'], $citySlug]) + ->execute(); + + } catch (CouchDBException $e) { + + Log::error( + sprintf( + 'An error occurred while searching a city in import users service. We search for citySlug: %s and county %s', + $citySlug, + serialize($county) + ) + ); + Log::error($e->getMessage()); + Log::error($e->getTraceAsString()); + + return null; + } + + if (!$getCity->offsetExists(0)) { + $this->errors = addError($this->errors, $citySlug, 'Orasul nu exista'); + + return null; + } + + $city = $getCity->offsetGet(0); + + //local cache + $this->citiesMap[$city[$citySlug]] = $city; + + return [ + "_id" => $city['id'], + "name" => $city['value'], + ]; + + } + + protected function getInstitution(?string $slug, User $importedBy): ?array + { + if (!$slug) { + return $importedBy->institution; + } + + $institution = Institution::where('slug', '=', $slug)->first(); + + if (!$institution) { + $this->errors = addError($this->errors, $slug, 'Institutia nu exista'); + + return null; + } + + /* + * ne trebuie ceva sa verificam daca $importedBy are voie sa faca import pe aceasta institutie + */ + + return ['_id' => $institution->_id, 'name' => $institution->name]; + } + + protected function getOrganisation(string $slug, User $importedBy): ?array + { + + if (!$slug) { + return null; + } + + $organisation = Organisation::where('slug', '=', $slug)->first(); + + if (!$organisation) { + $this->errors = addError($this->errors, $slug, 'Organizatia nu exista'); + + return null; + } + + /* + * ne trebuie ceva sa verificam daca $importedBy are voie sa faca import pe aceasta organizatie + */ + + return ['_id' => $organisation->_id, 'name' => $organisation->name]; + } + + protected function validateUser(array $userData) + { + //taken from UserController::store() + $rules = [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users.users', + 'role' => 'required', + 'phone' => 'required|string|min:6|', + ]; + + $validator = Validator::make($userData, $rules); + if ($validator->fails()) { + + foreach ($validator->errors()->messages() as $key => $error) { + + $this->errors = addError($this->errors, $key, $error); + } + + return false; + } + + return true; + } + + public function getErrors(): array + { + return $this->errors; + } + + public function getTotalImported(): int + { + return count($this->usersCreated); + } + + public function hasErrors(): bool + { + return count($this->errors) > 0; + } + + public function getCreatedUsers(): array + { + return $this->usersCreated; + } + +} \ No newline at end of file diff --git a/src/app/User.php b/src/app/User.php index 1ea5f26..684a8af 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -9,6 +9,12 @@ class User extends Authenticatable { use HasApiTokens, Notifiable; + + const ROLE_RESCUE_OFFICER = '0'; + const ROLE_INSTITUTION_ADMIN = '1'; + const ROLE_NGO_ADMIN = '2'; + const ROLE_DSU_ADMIN = '3'; + protected $connection = 'users'; protected $collection = 'users'; /** @@ -28,4 +34,5 @@ class User extends Authenticatable protected $hidden = [ 'password', 'remember_token', ]; + } diff --git a/src/config/roles.php b/src/config/roles.php index ff9dcac..0425a25 100644 --- a/src/config/roles.php +++ b/src/config/roles.php @@ -13,10 +13,10 @@ */ 'role' => [ - 'officer' => '0', - 'institution' => '1', - 'ngo' => '2', - 'dsu' => '3' + 'officer' => \App\User::ROLE_RESCUE_OFFICER, + 'institution' => \App\User::ROLE_INSTITUTION_ADMIN, + 'ngo' => \App\User::ROLE_NGO_ADMIN, + 'dsu' => \App\User::ROLE_DSU_ADMIN ], ]; diff --git a/src/database/migrations/2020_02_29_173548_add_slug_to_organisation.php b/src/database/migrations/2020_02_29_173548_add_slug_to_organisation.php new file mode 100644 index 0000000..852aaa9 --- /dev/null +++ b/src/database/migrations/2020_02_29_173548_add_slug_to_organisation.php @@ -0,0 +1,38 @@ +string('slug')->nullable(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'organisation', + function (Blueprint $table) { + $table->dropColumn('slug'); + } + ); + } +} diff --git a/src/routes/api.php b/src/routes/api.php index 6407108..5cbdc92 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -41,6 +41,7 @@ Route::middleware(['checkRole:dsu,institution'])->group(function () { Route::get('users', 'UserController@index'); Route::post('users', 'UserController@store'); + Route::post('users/import', 'UserController@importUsers'); }); Route::middleware(['checkRole:dsu,ngo'])->group(function () { diff --git a/src/storage/api-docs/api-docs.json b/src/storage/api-docs/api-docs.json old mode 100644 new mode 100755