Implemented user pictures (closes #1158)

This commit is contained in:
Bernd Bestel 2020-12-20 22:08:50 +01:00
parent 3f718eab60
commit 8f1ce607f7
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
15 changed files with 181 additions and 61 deletions

View File

@ -156,6 +156,7 @@
- Table states (visible columns, sorting, column order and so on) are now saved server side (in user settings) means that this stays the same when using different browsers - Table states (visible columns, sorting, column order and so on) are now saved server side (in user settings) means that this stays the same when using different browsers
- Dialogs are now used everywhere where appropriate instead of jumping between pages (for example when adding/editing shopping list items) - Dialogs are now used everywhere where appropriate instead of jumping between pages (for example when adding/editing shopping list items)
- Added a "Clear filter"-button on all pages (with filters) to quickly reset applied filters - Added a "Clear filter"-button on all pages (with filters) to quickly reset applied filters
- Users can now have a picture (will then be shown next to the current user name instead of the generic user icon)
- Prefilled number inputs now use sensible decimal places (max. the configured decimals while hiding trailing zeros where appropriate, means if you never use partial amounts for a product, you'll never see decimals for it) - Prefilled number inputs now use sensible decimal places (max. the configured decimals while hiding trailing zeros where appropriate, means if you never use partial amounts for a product, you'll never see decimals for it)
- Improved / more precise validation messages for number inputs - Improved / more precise validation messages for number inputs
- Ordering now happens case-insensitive - Ordering now happens case-insensitive

View File

@ -41,7 +41,7 @@ class UsersApiController extends BaseApiController
throw new \Exception('Request body could not be parsed (probably invalid JSON format or missing/wrong Content-Type header)'); throw new \Exception('Request body could not be parsed (probably invalid JSON format or missing/wrong Content-Type header)');
} }
$this->getUsersService()->CreateUser($requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password']); $this->getUsersService()->CreateUser($requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password'], $requestBody['picture_file_name']);
return $this->EmptyApiResponse($response); return $this->EmptyApiResponse($response);
} }
catch (\Exception $ex) catch (\Exception $ex)
@ -79,7 +79,7 @@ class UsersApiController extends BaseApiController
try try
{ {
$this->getUsersService()->EditUser($args['userId'], $requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password']); $this->getUsersService()->EditUser($args['userId'], $requestBody['username'], $requestBody['first_name'], $requestBody['last_name'], $requestBody['password'], $requestBody['picture_file_name']);
return $this->EmptyApiResponse($response); return $this->EmptyApiResponse($response);
} }
catch (\Exception $ex) catch (\Exception $ex)

View File

@ -22,7 +22,8 @@ class UsersController extends BaseController
{ {
User::checkPermission($request, User::PERMISSION_USERS_CREATE); User::checkPermission($request, User::PERMISSION_USERS_CREATE);
return $this->renderPage($response, 'userform', [ return $this->renderPage($response, 'userform', [
'mode' => 'create' 'mode' => 'create',
'userfields' => $this->getUserfieldsService()->GetFields('users')
]); ]);
} }
else else
@ -38,7 +39,9 @@ class UsersController extends BaseController
return $this->renderPage($response, 'userform', [ return $this->renderPage($response, 'userform', [
'user' => $this->getDatabase()->users($args['userId']), 'user' => $this->getDatabase()->users($args['userId']),
'mode' => 'edit' 'mode' => 'edit',
'userfields' => $this->getUserfieldsService()->GetFields('users'),
'userfieldValues' => $this->getUserfieldsService()->GetAllValues('users')
]); ]);
} }
} }

View File

@ -4606,6 +4606,9 @@
"password": { "password": {
"type": "string" "type": "string"
}, },
"picture_file_name": {
"type": "string"
},
"row_created_timestamp": { "row_created_timestamp": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
@ -4631,6 +4634,9 @@
"display_name": { "display_name": {
"type": "string" "type": "string"
}, },
"picture_file_name": {
"type": "string"
},
"row_created_timestamp": { "row_created_timestamp": {
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"

View File

@ -742,9 +742,6 @@ msgstr ""
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
msgid "The current picture will be deleted when you save the product"
msgstr ""
msgid "Select file" msgid "Select file"
msgstr "" msgstr ""
@ -958,7 +955,7 @@ msgstr ""
msgid "Gallery" msgid "Gallery"
msgstr "" msgstr ""
msgid "The current picture will be deleted when you save the recipe" msgid "The current picture will be deleted on save"
msgstr "" msgstr ""
msgid "Journal for this battery" msgid "Journal for this battery"

View File

@ -43,6 +43,7 @@ abstract class AuthMiddleware extends BaseMiddleware
define('GROCY_AUTHENTICATED', true); define('GROCY_AUTHENTICATED', true);
define('GROCY_USER_USERNAME', $user->username); define('GROCY_USER_USERNAME', $user->username);
define('GROCY_USER_PICTURE_FILE_NAME', $user->picture_file_name);
return $handler->handle($request); return $handler->handle($request);
} }
@ -70,6 +71,7 @@ abstract class AuthMiddleware extends BaseMiddleware
define('GROCY_AUTHENTICATED', true); define('GROCY_AUTHENTICATED', true);
define('GROCY_USER_ID', $user->id); define('GROCY_USER_ID', $user->id);
define('GROCY_USER_USERNAME', $user->username); define('GROCY_USER_USERNAME', $user->username);
define('GROCY_USER_PICTURE_FILE_NAME', $user->picture_file_name);
return $response = $handler->handle($request); return $response = $handler->handle($request);
} }

21
migrations/0125.sql Normal file
View File

@ -0,0 +1,21 @@
ALTER TABLE users
ADD picture_file_name TEXT;
DROP VIEW users_dto;
CREATE VIEW users_dto
AS
SELECT
id,
username,
first_name,
last_name,
row_created_timestamp,
(CASE
WHEN IFNULL(first_name, '') = '' AND IFNULL(last_name, '') != '' THEN last_name
WHEN IFNULL(last_name, '') = '' AND IFNULL(first_name, '') != '' THEN first_name
WHEN IFNULL(last_name, '') != '' AND IFNULL(first_name, '') != '' THEN first_name || ' ' || last_name
ELSE username
END
) AS display_name,
picture_file_name
FROM users;

View File

@ -379,36 +379,3 @@ $(window).on("message", function(e)
); );
} }
}); });
// Grocy.Components.RecipePicker.GetPicker().on('change', function (e)
// {
// var value = Grocy.Components.RecipePicker.GetValue();
// if (value.toString().isEmpty())
// {
// return;
// }
// Grocy.Api.Get('objects/recipes/' + value,
// function(recipe)
// {
// $("#includes_servings").val(recipe.servings);
// },
// function(xhr)
// {
// console.error(xhr);
// }
// );
// });
// Grocy.Components.ProductPicker.GetPicker().on('change', function(e)
// {
// // Just save the current recipe on every change of the product picker as a workflow could be started which leaves the page...
// Grocy.Api.Put('objects/recipes/' + Grocy.EditObjectId, $('#recipe-form').serializeJSON(), function () { }, function () { });
// });
// As the /recipe/new route immediately creates a new recipe on load,
// always replace the current location by the created recipes edit page location
// if (window.location.pathname.toLowerCase() === "/recipe/new")
// {
// window.history.replaceState(null, null, U("/recipe/" + Grocy.EditObjectId));
// }

View File

@ -1,17 +1,46 @@
$('#save-user-button').on('click', function(e) function SaveUserPicture(result, jsonData)
{
var userId = Grocy.EditObjectId || result.created_object_id;
Grocy.Components.UserfieldsForm.Save(() =>
{
if (jsonData.hasOwnProperty("picture_file_name") && !Grocy.DeleteUserPictureOnSave)
{
Grocy.Api.UploadFile($("#user-picture")[0].files[0], 'userpictures', jsonData.picture_file_name,
(result) =>
{
window.location.href = U('/users');
},
(xhr) =>
{
Grocy.FrontendHelpers.EndUiBusy("user-form");
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
}
);
}
else
{
window.location.href = U('/users');
}
});
}
$('#save-user-button').on('click', function(e)
{ {
e.preventDefault(); e.preventDefault();
var jsonData = $('#user-form').serializeJSON(); var jsonData = $('#user-form').serializeJSON();
Grocy.FrontendHelpers.BeginUiBusy("user-form"); Grocy.FrontendHelpers.BeginUiBusy("user-form");
if ($("#user-picture")[0].files.length > 0)
{
var someRandomStuff = Math.random().toString(36).substring(2, 100) + Math.random().toString(36).substring(2, 100);
jsonData.picture_file_name = someRandomStuff + $("#user-picture")[0].files[0].name;
}
if (Grocy.EditMode === 'create') if (Grocy.EditMode === 'create')
{ {
Grocy.Api.Post('users', jsonData, Grocy.Api.Post('users', jsonData,
function(result) (result) => SaveUserPicture(result, jsonData),
{
window.location.href = U('/users');
},
function(xhr) function(xhr)
{ {
Grocy.FrontendHelpers.EndUiBusy("user-form"); Grocy.FrontendHelpers.EndUiBusy("user-form");
@ -21,12 +50,26 @@
} }
else else
{ {
Grocy.Api.Put('users/' + Grocy.EditObjectId, jsonData, if (Grocy.DeleteUserPictureOnSave)
{
jsonData.picture_file_name = null;
Grocy.Api.DeleteFile(Grocy.UserPictureFileName, 'userpictures', {},
function(result) function(result)
{ {
window.location.href = U('/users'); // Nothing to do
}, },
function(xhr) function(xhr)
{
Grocy.FrontendHelpers.EndUiBusy("user-form");
Grocy.FrontendHelpers.ShowGenericError('Error while saving, probably this item already exists', xhr.response)
}
);
}
Grocy.Api.Put('users/' + Grocy.EditObjectId, jsonData,
(result) => SaveUserPicture(result, jsonData),
function(xhr)
{ {
Grocy.FrontendHelpers.EndUiBusy("user-form"); Grocy.FrontendHelpers.EndUiBusy("user-form");
console.error(xhr); console.error(xhr);
@ -76,4 +119,23 @@ else
$('#username').focus(); $('#username').focus();
} }
$("#user-picture").on("change", function(e)
{
$("#user-picture-label").removeClass("d-none");
$("#user-picture-label-none").addClass("d-none");
$("#delete-current-user-picture-on-save-hint").addClass("d-none");
$("#current-user-picture").addClass("d-none");
Grocy.DeleteUserePictureOnSave = false;
});
Grocy.DeleteUserPictureOnSave = false;
$("#delete-current-user-picture-button").on("click", function(e)
{
Grocy.DeleteUserPictureOnSave = true;
$("#current-user-picture").addClass("d-none");
$("#delete-current-user-picture-on-save-hint").removeClass("d-none");
$("#user-picture-label").addClass("d-none");
$("#user-picture-label-none").removeClass("d-none");
});
Grocy.FrontendHelpers.ValidateForm('user-form'); Grocy.FrontendHelpers.ValidateForm('user-form');

View File

@ -37,15 +37,15 @@ class UserfieldsService extends BaseService
public function GetEntities() public function GetEntities()
{ {
$exposedDefaultEntities = $this->getOpenApiSpec()->components->internalSchemas->ExposedEntity->enum; $exposedDefaultEntities = $this->getOpenApiSpec()->components->internalSchemas->ExposedEntity->enum;
$userEntities = [];
$userentities = []; $specialEntities = ['users'];
foreach ($this->getDatabase()->userentities()->orderBy('name', 'COLLATE NOCASE') as $userentity) foreach ($this->getDatabase()->userentities()->orderBy('name', 'COLLATE NOCASE') as $userentity)
{ {
$userentities[] = 'userentity-' . $userentity->name; $userEntities[] = 'userentity-' . $userentity->name;
} }
return array_merge($exposedDefaultEntities, $userentities); return array_merge($exposedDefaultEntities, $userEntities, $specialEntities);
} }
public function GetField($fieldId) public function GetField($fieldId)

View File

@ -4,13 +4,14 @@ namespace Grocy\Services;
class UsersService extends BaseService class UsersService extends BaseService
{ {
public function CreateUser(string $username, string $firstName, string $lastName, string $password) public function CreateUser(string $username, string $firstName, string $lastName, string $password, string $pictureFileName = null)
{ {
$newUserRow = $this->getDatabase()->users()->createRow([ $newUserRow = $this->getDatabase()->users()->createRow([
'username' => $username, 'username' => $username,
'first_name' => $firstName, 'first_name' => $firstName,
'last_name' => $lastName, 'last_name' => $lastName,
'password' => password_hash($password, PASSWORD_DEFAULT) 'password' => password_hash($password, PASSWORD_DEFAULT),
'picture_file_name' => $pictureFileName
]); ]);
$newUserRow = $newUserRow->save(); $newUserRow = $newUserRow->save();
$permList = []; $permList = [];
@ -34,7 +35,7 @@ class UsersService extends BaseService
$row->delete(); $row->delete();
} }
public function EditUser(int $userId, string $username, string $firstName, string $lastName, string $password) public function EditUser(int $userId, string $username, string $firstName, string $lastName, string $password, string $pictureFileName = null)
{ {
if (!$this->UserExists($userId)) if (!$this->UserExists($userId))
{ {
@ -46,7 +47,8 @@ class UsersService extends BaseService
'username' => $username, 'username' => $username,
'first_name' => $firstName, 'first_name' => $firstName,
'last_name' => $lastName, 'last_name' => $lastName,
'password' => password_hash($password, PASSWORD_DEFAULT) 'password' => password_hash($password, PASSWORD_DEFAULT),
'picture_file_name' => $pictureFileName
]); ]);
} }

View File

@ -478,7 +478,15 @@
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle discrete-link" <a class="nav-link dropdown-toggle discrete-link"
href="#" href="#"
data-toggle="dropdown"><i class="fas fa-user"></i> {{ GROCY_USER_USERNAME }}</a> data-toggle="dropdown">
@if(empty(GROCY_USER_PICTURE_FILE_NAME))
<i class="fas fa-user"></i>
@else
<img class="lazy rounded-circle mt-n1"
src="{{ $U('/files/userpictures/' . base64_encode(GROCY_USER_PICTURE_FILE_NAME) . '_' . base64_encode(GROCY_USER_PICTURE_FILE_NAME) . '?force_serve_as=picture&best_fit_width=16&best_fit_height=16') }}">
@endif
{{ GROCY_USER_USERNAME }}
</a>
<div class="dropdown-menu dropdown-menu-right"> <div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item logout-button discrete-link" <a class="dropdown-item logout-button discrete-link"

View File

@ -661,7 +661,7 @@
data-src="{{ $U('/api/files/productpictures/' . base64_encode($product->picture_file_name) . '?force_serve_as=picture&best_fit_width=400') }}" data-src="{{ $U('/api/files/productpictures/' . base64_encode($product->picture_file_name) . '?force_serve_as=picture&best_fit_width=400') }}"
class="img-fluid img-thumbnail mt-2 lazy mb-5"> class="img-fluid img-thumbnail mt-2 lazy mb-5">
<p id="delete-current-product-picture-on-save-hint" <p id="delete-current-product-picture-on-save-hint"
class="form-text text-muted font-italic d-none mb-5">{{ $__t('The current picture will be deleted when you save the product') }}</p> class="form-text text-muted font-italic d-none mb-5">{{ $__t('The current picture will be deleted on save') }}</p>
@else @else
<p id="no-current-product-picture-hint" <p id="no-current-product-picture-hint"
class="form-text text-muted font-italic mb-5">{{ $__t('No picture available') }}</p> class="form-text text-muted font-italic mb-5">{{ $__t('No picture available') }}</p>

View File

@ -319,7 +319,7 @@
data-src="{{ $U('/api/files/recipepictures/' . base64_encode($recipe->picture_file_name) . '?force_serve_as=picture&best_fit_width=400') }}" data-src="{{ $U('/api/files/recipepictures/' . base64_encode($recipe->picture_file_name) . '?force_serve_as=picture&best_fit_width=400') }}"
class="img-fluid img-thumbnail mt-2 lazy mb-5"> class="img-fluid img-thumbnail mt-2 lazy mb-5">
<p id="delete-current-recipe-picture-on-save-hint" <p id="delete-current-recipe-picture-on-save-hint"
class="form-text text-muted font-italic d-none mb-5">{{ $__t('The current picture will be deleted when you save the recipe') }}</p> class="form-text text-muted font-italic d-none mb-5">{{ $__t('The current picture will be deleted on save') }}</p>
@else @else
<p id="no-current-recipe-picture-hint" <p id="no-current-recipe-picture-hint"
class="form-text text-muted font-italic mb-5">{{ $__t('No picture available') }}</p> class="form-text text-muted font-italic mb-5">{{ $__t('No picture available') }}</p>

View File

@ -26,6 +26,10 @@
@if($mode == 'edit') @if($mode == 'edit')
<script> <script>
Grocy.EditObjectId = {{ $user->id }}; Grocy.EditObjectId = {{ $user->id }};
@if(!empty($user->picture_file_name))
Grocy.UserPictureFileName = '{{ $user->picture_file_name }}';
@endif
</script> </script>
@endif @endif
@ -80,10 +84,57 @@
<div class="invalid-feedback">{{ $__t('Passwords do not match') }}</div> <div class="invalid-feedback">{{ $__t('Passwords do not match') }}</div>
</div> </div>
@include('components.userfieldsform', array(
'userfields' => $userfields,
'entity' => 'users'
))
<button id="save-user-button" <button id="save-user-button"
class="btn btn-success">{{ $__t('Save') }}</button> class="btn btn-success">{{ $__t('Save') }}</button>
</form> </form>
</div> </div>
<div class="col-lg-6 col-xs-12">
<div class="title-related-links">
<h4>
{{ $__t('Picture') }}
</h4>
<div class="form-group w-75 m-0">
<div class="input-group">
<div class="custom-file">
<input type="file"
class="custom-file-input"
id="user-picture"
accept="image/*">
<label id="user-picture-label"
class="custom-file-label @if(empty($user->picture_file_name)) d-none @endif"
for="user-picture">
{{ $user->picture_file_name }}
</label>
<label id="user-picture-label-none"
class="custom-file-label @if(!empty($user->picture_file_name)) d-none @endif"
for="user-picture">
{{ $__t('No file selected') }}
</label>
</div>
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-trash"
id="delete-current-user-picture-button"></i></span>
</div>
</div>
</div>
</div>
@if(!empty($user->picture_file_name))
<img id="current-user-picture"
data-src="{{ $U('/api/files/userpictures/' . base64_encode($user->picture_file_name) . '?force_serve_as=picture&best_fit_width=400') }}"
class="img-fluid img-thumbnail mt-2 lazy mb-5">
<p id="delete-current-user-picture-on-save-hint"
class="form-text text-muted font-italic d-none mb-5">{{ $__t('The current picture will be deleted on save') }}</p>
@else
<p id="no-current-user-picture-hint"
class="form-text text-muted font-italic mb-5">{{ $__t('No picture available') }}</p>
@endif
</div>
</div> </div>
@stop @stop