Added permissions schema per entity fields
now api has ability to set per user role ( group ) which fields is allowed to edit per role and per entity Change-Id: I824ede72dfe9e68b55531a8d2685aa911cb3270e
This commit is contained in:
parent
6abf5a30ac
commit
4f6aac0d4c
@ -23,6 +23,7 @@ use Illuminate\Support\Facades\Validator;
|
||||
use libs\utils\HTMLCleaner;
|
||||
use models\exceptions\EntityNotFoundException;
|
||||
use models\exceptions\ValidationException;
|
||||
use models\main\IMemberRepository;
|
||||
use models\oauth2\IResourceServerContext;
|
||||
use models\summit\IEventFeedbackRepository;
|
||||
use models\summit\ISpeakerRepository;
|
||||
@ -62,6 +63,11 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController
|
||||
*/
|
||||
private $event_feedback_repository;
|
||||
|
||||
/**
|
||||
* @var IMemberRepository
|
||||
*/
|
||||
private $member_repository;
|
||||
|
||||
|
||||
public function __construct
|
||||
(
|
||||
@ -69,6 +75,7 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController
|
||||
ISummitEventRepository $event_repository,
|
||||
ISpeakerRepository $speaker_repository,
|
||||
IEventFeedbackRepository $event_feedback_repository,
|
||||
IMemberRepository $member_repository,
|
||||
ISummitService $service,
|
||||
IResourceServerContext $resource_server_context
|
||||
) {
|
||||
@ -77,6 +84,7 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController
|
||||
$this->speaker_repository = $speaker_repository;
|
||||
$this->event_repository = $event_repository;
|
||||
$this->event_feedback_repository = $event_feedback_repository;
|
||||
$this->member_repository = $member_repository;
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
@ -408,6 +416,12 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController
|
||||
if(!Request::isJson()) return $this->error400();
|
||||
$data = Input::json();
|
||||
|
||||
$current_member = null;
|
||||
$member_id = $this->resource_server_context->getCurrentUserExternalId();
|
||||
if (!is_null($member_id)){
|
||||
$current_member = $this->member_repository->getById($member_id);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
// summit event rules
|
||||
'title' => 'sometimes|string|max:100',
|
||||
@ -427,13 +441,14 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController
|
||||
'tags' => 'sometimes|string_array',
|
||||
'sponsors' => 'sometimes|int_array',
|
||||
// presentation rules
|
||||
'attendees_expected_learnt' => 'sometimes|string|max:1000',
|
||||
'attending_media' => 'sometimes|boolean',
|
||||
'to_record' => 'sometimes|boolean',
|
||||
'speakers' => 'sometimes|int_array',
|
||||
'moderator_speaker_id' => 'sometimes|integer',
|
||||
'attendees_expected_learnt' => 'sometimes|string|max:1000',
|
||||
'attending_media' => 'sometimes|boolean',
|
||||
'to_record' => 'sometimes|boolean',
|
||||
'speakers' => 'sometimes|int_array',
|
||||
'moderator_speaker_id' => 'sometimes|integer',
|
||||
// group event
|
||||
'groups' => 'sometimes|int_array',
|
||||
'groups' => 'sometimes|int_array',
|
||||
'occupancy' => 'sometimes|in:EMPTY,25%,50%,75%,FULL'
|
||||
];
|
||||
|
||||
// Creates a Validator instance and validates the data.
|
||||
@ -454,7 +469,7 @@ final class OAuth2SummitEventsApiController extends OAuth2ProtectedController
|
||||
'social_summary',
|
||||
];
|
||||
|
||||
$event = $this->service->updateEvent($summit, $event_id, HTMLCleaner::cleanData($data->all(), $fields));
|
||||
$event = $this->service->updateEvent($summit, $event_id, HTMLCleaner::cleanData($data->all(), $fields), $current_member);
|
||||
|
||||
return $this->ok(SerializerRegistry::getInstance()->getSerializer($event)->serialize());
|
||||
|
||||
|
@ -258,7 +258,7 @@ Route::group([
|
||||
|
||||
Route::get('', 'OAuth2SummitEventsApiController@getEvent');
|
||||
Route::get('/published', [ 'middleware' => 'cache:'.Config::get('cache_api_response.get_published_event_response_lifetime', 300), 'uses' => 'OAuth2SummitEventsApiController@getScheduledEvent']);
|
||||
Route::put('', [ 'middleware' => 'auth.user:administrators|summit-front-end-administrators', 'uses' => 'OAuth2SummitEventsApiController@updateEvent' ]);
|
||||
Route::put('', [ 'middleware' => 'auth.user:administrators|summit-front-end-administrators|summit-room-administrators', 'uses' => 'OAuth2SummitEventsApiController@updateEvent' ]);
|
||||
Route::delete('', [ 'middleware' => 'auth.user:administrators|summit-front-end-administrators', 'uses' => 'OAuth2SummitEventsApiController@deleteEvent' ]);
|
||||
Route::put('/publish', [ 'middleware' => 'auth.user:administrators|summit-front-end-administrators', 'uses' => 'OAuth2SummitEventsApiController@publishEvent']);
|
||||
Route::delete('/publish', [ 'middleware' => 'auth.user:administrators|summit-front-end-administrators', 'uses' => 'OAuth2SummitEventsApiController@unPublishEvent']);
|
||||
|
@ -11,6 +11,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
use App\Models\Foundation\Main\IGroup;
|
||||
use models\main\IMemberRepository;
|
||||
use models\oauth2\IResourceServerContext;
|
||||
use models\main\Group;
|
||||
@ -54,7 +55,7 @@ final class BaseSerializerTypeSelector implements ISerializerTypeSelector
|
||||
$serializer_type = SerializerRegistry::SerializerType_Public;
|
||||
$current_member_id = $this->resource_server_context->getCurrentUserExternalId();
|
||||
if(!is_null($current_member_id) && $member = $this->member_repository->getById($current_member_id)){
|
||||
if($member->isOnGroup(Group::SummitAdministrators)){
|
||||
if($member->isOnGroup(IGroup::SummitAdministrators)){
|
||||
$serializer_type = SerializerRegistry::SerializerType_Private;
|
||||
}
|
||||
}
|
||||
|
@ -23,10 +23,6 @@ use models\utils\SilverstripeBaseModel;
|
||||
*/
|
||||
class Group extends SilverstripeBaseModel
|
||||
{
|
||||
const AdminGroupCode = 'administrators';
|
||||
const CommunityMembersCode = 'community-members';
|
||||
const FoundationMembersCode = 'foundation-members';
|
||||
const SummitAdministrators = 'summit-front-end-administrators';
|
||||
|
||||
public function __construct(){
|
||||
parent::__construct();
|
||||
|
28
app/Models/Foundation/Main/IGroup.php
Normal file
28
app/Models/Foundation/Main/IGroup.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php namespace App\Models\Foundation\Main;
|
||||
|
||||
/**
|
||||
* Copyright 2018 OpenStack Foundation
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
|
||||
|
||||
/**
|
||||
* Interface IGroup
|
||||
* @package App\Models\Foundation\Main
|
||||
*/
|
||||
interface IGroup
|
||||
{
|
||||
const Administrators = 'administrators';
|
||||
const CommunityMembers = 'community-members';
|
||||
const FoundationMembers = 'foundation-members';
|
||||
const SummitAdministrators = 'summit-front-end-administrators';
|
||||
const SummitRoomAdministrators = 'summit-room-administrators';
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
use App\Models\Foundation\Main\IGroup;
|
||||
use Models\Foundation\Main\CCLA\Team;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Criteria;
|
||||
@ -580,7 +581,7 @@ class Member extends SilverstripeBaseModel
|
||||
*/
|
||||
public function isAdmin()
|
||||
{
|
||||
$admin_group = $this->getGroupByCode(Group::AdminGroupCode);
|
||||
$admin_group = $this->getGroupByCode(IGroup::Administrators);
|
||||
return $admin_group != false && !is_null($admin_group);
|
||||
}
|
||||
|
||||
|
@ -58,6 +58,12 @@ class SummitEvent extends SilverstripeBaseModel
|
||||
*/
|
||||
protected $social_summary;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="Occupancy", type="string")
|
||||
* @var string
|
||||
*/
|
||||
protected $occupancy;
|
||||
|
||||
/**
|
||||
* @ORM\Column(name="StartDate", type="datetime")
|
||||
* @var \DateTime
|
||||
@ -907,4 +913,21 @@ class SummitEvent extends SilverstripeBaseModel
|
||||
$this->rsvp_max_user_wait_list_number = $rsvp_max_user_wait_list_number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getOccupancy()
|
||||
{
|
||||
return $this->occupancy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $occupancy
|
||||
*/
|
||||
public function setOccupancy($occupancy)
|
||||
{
|
||||
$this->occupancy = $occupancy;
|
||||
}
|
||||
|
||||
|
||||
}
|
28
app/Permissions/IPermissionsManager.php
Normal file
28
app/Permissions/IPermissionsManager.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php namespace App\Permissions;
|
||||
/**
|
||||
* Copyright 2018 OpenStack Foundation
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
use models\main\Member;
|
||||
/**
|
||||
* Interface IPermissionsManager
|
||||
* @package App\Permissions
|
||||
*/
|
||||
interface IPermissionsManager
|
||||
{
|
||||
/**
|
||||
* @param Member $current_user
|
||||
* @param string $entity_name
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public function canEditFields(Member $current_user, $entity_name, array $data);
|
||||
}
|
82
app/Permissions/PermissionsManager.php
Normal file
82
app/Permissions/PermissionsManager.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php namespace App\Permissions;
|
||||
/**
|
||||
* Copyright 2018 OpenStack Foundation
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
use models\main\Member;
|
||||
use Symfony\Component\Yaml\Exception\ParseException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
/**
|
||||
* Class PermissionsManager
|
||||
* @package App\Permissions
|
||||
*/
|
||||
final class PermissionsManager implements IPermissionsManager
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $config = [];
|
||||
|
||||
const AllFieldsWillCard = "__all__";
|
||||
|
||||
private function loadConfiguration(){
|
||||
if(count($this->config) > 0 ) return;
|
||||
|
||||
$path = sprintf("%s/permissions.yml", dirname(__FILE__));
|
||||
$yaml = Yaml::parse(file_get_contents($path));
|
||||
if(!is_null($yaml) && count($yaml))
|
||||
{
|
||||
foreach($yaml as $entities => $entity){
|
||||
foreach ($entity as $permissions_per_role){
|
||||
$entity_name = array_keys($entity)[0];
|
||||
$entity_config = [];
|
||||
foreach ($permissions_per_role as $permission_per_role){
|
||||
$role = array_keys($permission_per_role)[0];
|
||||
$entity_config[$role] = [];
|
||||
foreach($permission_per_role as $permissions){
|
||||
foreach($permissions as $permission)
|
||||
$entity_config[$role][] = $permission;
|
||||
}
|
||||
}
|
||||
$this->config[$entity_name] = $entity_config;
|
||||
}
|
||||
}}
|
||||
|
||||
}
|
||||
/**
|
||||
* @param Member $current_user
|
||||
* @param string $entity_name
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
public function canEditFields(Member $current_user, $entity_name, array $data){
|
||||
$this->loadConfiguration();
|
||||
$groups = $current_user->getGroupsCodes();
|
||||
|
||||
if(!isset($this->config[$entity_name]))
|
||||
throw new \InvalidArgumentException(sprintf("entity %s does not has a configuration set", $entity_name));
|
||||
|
||||
$entity_config = $this->config[$entity_name];
|
||||
|
||||
foreach($groups as $group_code) {
|
||||
if(!isset($entity_config[$group_code])) continue;
|
||||
$allowed_fields = $entity_config[$group_code];
|
||||
if(count($allowed_fields) == 0) return false;
|
||||
if(in_array(self::AllFieldsWillCard, $allowed_fields)) return true;
|
||||
$fields = array_keys($data);
|
||||
$diff = array_diff($fields, $allowed_fields);
|
||||
if(count($diff) > 0)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
13
app/Permissions/permissions.yml
Normal file
13
app/Permissions/permissions.yml
Normal file
@ -0,0 +1,13 @@
|
||||
# here you define per Entity ClassName allowed fields to edit per user role
|
||||
- SummitEvent:
|
||||
- administrators:
|
||||
- __all__
|
||||
- summit-front-end-administrators:
|
||||
- __all__
|
||||
- summit-room-administrators:
|
||||
- occupancy
|
||||
- Presentation:
|
||||
- administrators:
|
||||
- __all__
|
||||
- summit-front-end-administrators:
|
||||
- __all__
|
@ -11,6 +11,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
use App\Models\Foundation\Main\IGroup;
|
||||
use Doctrine\ORM\Tools\Pagination\Paginator;
|
||||
use models\main\Group;
|
||||
use models\summit\ISummitEventRepository;
|
||||
@ -214,7 +215,7 @@ final class DoctrineSummitEventRepository
|
||||
$query = $query->leftJoin('sp.registration_request', "sprr", Join::LEFT_JOIN);
|
||||
}
|
||||
|
||||
$can_view_private_events = self::isCurrentMemberOnGroup(Group::SummitAdministrators);
|
||||
$can_view_private_events = self::isCurrentMemberOnGroup(IGroup::SummitAdministrators);
|
||||
|
||||
if(!$can_view_private_events){
|
||||
$query = $query
|
||||
@ -305,7 +306,7 @@ final class DoctrineSummitEventRepository
|
||||
}
|
||||
|
||||
|
||||
$can_view_private_events = self::isCurrentMemberOnGroup(Group::SummitAdministrators);
|
||||
$can_view_private_events = self::isCurrentMemberOnGroup(IGroup::SummitAdministrators);
|
||||
|
||||
if(!$can_view_private_events){
|
||||
$query = $query
|
||||
|
@ -40,9 +40,10 @@ interface ISummitService
|
||||
* @param Summit $summit
|
||||
* @param int $event_id
|
||||
* @param array $data
|
||||
* @param null|Member $current_member
|
||||
* @return SummitEvent
|
||||
*/
|
||||
public function updateEvent(Summit $summit, $event_id, array $data);
|
||||
public function updateEvent(Summit $summit, $event_id, array $data, Member $current_member = null);
|
||||
|
||||
/**
|
||||
* @param Summit $summit
|
||||
|
@ -21,6 +21,7 @@ use App\Http\Utils\FileUploader;
|
||||
use App\Models\Foundation\Summit\Factories\SummitFactory;
|
||||
use App\Models\Foundation\Summit\Repositories\IDefaultSummitEventTypeRepository;
|
||||
use App\Models\Utils\IntervalParser;
|
||||
use App\Permissions\IPermissionsManager;
|
||||
use App\Services\Model\AbstractService;
|
||||
use App\Services\Model\IFolderService;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
@ -161,6 +162,11 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
*/
|
||||
private $default_event_types_repository;
|
||||
|
||||
/**
|
||||
* @var IPermissionsManager
|
||||
*/
|
||||
private $permissions_manager;
|
||||
|
||||
/**
|
||||
* SummitService constructor.
|
||||
* @param ISummitRepository $summit_repository
|
||||
@ -178,6 +184,7 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
* @param ICompanyRepository $company_repository
|
||||
* @param IGroupRepository $group_repository,
|
||||
* @param IDefaultSummitEventTypeRepository $default_event_types_repository
|
||||
* @param IPermissionsManager $permissions_manager
|
||||
* @param ITransactionService $tx_service
|
||||
*/
|
||||
public function __construct
|
||||
@ -197,6 +204,7 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
ICompanyRepository $company_repository,
|
||||
IGroupRepository $group_repository,
|
||||
IDefaultSummitEventTypeRepository $default_event_types_repository,
|
||||
IPermissionsManager $permissions_manager,
|
||||
ITransactionService $tx_service
|
||||
)
|
||||
{
|
||||
@ -216,6 +224,7 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
$this->company_repository = $company_repository;
|
||||
$this->group_repository = $group_repository;
|
||||
$this->default_event_types_repository = $default_event_types_repository;
|
||||
$this->permissions_manager = $permissions_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -529,11 +538,12 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
* @param Summit $summit
|
||||
* @param int $event_id
|
||||
* @param array $data
|
||||
* @param null|Member $current_member
|
||||
* @return SummitEvent
|
||||
*/
|
||||
public function updateEvent(Summit $summit, $event_id, array $data)
|
||||
public function updateEvent(Summit $summit, $event_id, array $data, Member $current_member = null)
|
||||
{
|
||||
return $this->saveOrUpdateEvent($summit, $data, $event_id);
|
||||
return $this->saveOrUpdateEvent($summit, $data, $event_id, $current_member);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -616,16 +626,25 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param Summit $summit
|
||||
* @param array $data
|
||||
* @param null|int $event_id
|
||||
* @param Member|null $current_member
|
||||
* @return SummitEvent
|
||||
* @throws EntityNotFoundException
|
||||
* @throws ValidationException
|
||||
* @throws Exception
|
||||
*/
|
||||
private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null)
|
||||
private function saveOrUpdateEvent(Summit $summit, array $data, $event_id = null, Member $current_member = null)
|
||||
{
|
||||
|
||||
return $this->tx_service->transaction(function () use ($summit, $data, $event_id) {
|
||||
return $this->tx_service->transaction(function () use ($summit, $data, $event_id, $current_member) {
|
||||
|
||||
if(!is_null($current_member) && !$this->permissions_manager->canEditFields($current_member, 'SummitEvent', $data)){
|
||||
throw new ValidationException(sprintf("user %s cant set requested summit event fields", $current_member->getId()));
|
||||
}
|
||||
|
||||
$event_type = null;
|
||||
|
||||
@ -721,6 +740,9 @@ final class SummitService extends AbstractService implements ISummitService
|
||||
if (isset($data['social_description']))
|
||||
$event->setSocialSummary(strip_tags(trim($data['social_description'])));
|
||||
|
||||
if (isset($data['occupancy']))
|
||||
$event->setOccupancy($data['occupancy']);
|
||||
|
||||
$event->setAllowFeedBack(isset($data['allow_feedback'])?
|
||||
filter_var($data['allow_feedback'], FILTER_VALIDATE_BOOLEAN) :
|
||||
false);
|
||||
|
@ -11,6 +11,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
**/
|
||||
use App\Permissions\IPermissionsManager;
|
||||
use App\Permissions\PermissionsManager;
|
||||
use App\Services\Apis\CalendarSync\ICalendarSyncRemoteFacadeFactory;
|
||||
use App\Services\Apis\GoogleGeoCodingAPI;
|
||||
use App\Services\Apis\IGeoCodingAPI;
|
||||
@ -72,6 +74,8 @@ final class ServicesProvider extends ServiceProvider
|
||||
{
|
||||
App::singleton(ICacheService::class, RedisCacheService::class);
|
||||
|
||||
App::singleton(IPermissionsManager::class, PermissionsManager::class);
|
||||
|
||||
App::singleton(\libs\utils\ITransactionService::class, function(){
|
||||
return new \services\utils\DoctrineTransactionService('ss');
|
||||
});
|
||||
|
@ -335,6 +335,43 @@ final class OAuth2SummitEventsApiTest extends ProtectedApiTest
|
||||
return $event;
|
||||
}
|
||||
|
||||
public function testUpdateEventOccupancy(){
|
||||
|
||||
$params = array
|
||||
(
|
||||
'id' => 23,
|
||||
'event_id' => 20345,
|
||||
);
|
||||
|
||||
$data = [
|
||||
'occupancy' => '25%'
|
||||
];
|
||||
|
||||
$headers = array
|
||||
(
|
||||
"HTTP_Authorization" => " Bearer " . $this->access_token,
|
||||
"CONTENT_TYPE" => "application/json"
|
||||
);
|
||||
|
||||
$response = $this->action
|
||||
(
|
||||
"PUT",
|
||||
"OAuth2SummitEventsApiController@updateEvent",
|
||||
$params,
|
||||
array(),
|
||||
array(),
|
||||
array(),
|
||||
$headers,
|
||||
json_encode($data)
|
||||
);
|
||||
|
||||
$content = $response->getContent();
|
||||
$this->assertResponseStatus(200);
|
||||
$event = json_decode($content);
|
||||
$this->assertTrue($event->id > 0);
|
||||
return $event;
|
||||
}
|
||||
|
||||
public function testUnPublishEvent()
|
||||
{
|
||||
$event = $this->testPublishEvent(1461529800, 1461533400);
|
||||
|
Loading…
x
Reference in New Issue
Block a user