Merge "Removed inline_edit functionality for tables"
This commit is contained in:
commit
de29bbce85
@ -90,7 +90,7 @@ Actions
|
||||
.. autoclass:: DeleteAction
|
||||
:members:
|
||||
|
||||
.. autoclass:: UpdateAction
|
||||
.. autoclass:: UpdateAction **DEPRECATED**
|
||||
:members:
|
||||
|
||||
Class-Based Views
|
||||
|
@ -288,8 +288,8 @@ Example::
|
||||
admin=True)
|
||||
return project_info
|
||||
|
||||
Updating changed cell data
|
||||
--------------------------
|
||||
Updating changed cell data (DEPRECATED)
|
||||
---------------------------------------
|
||||
|
||||
Define an ``update_cell`` method in the class inherited from
|
||||
``tables.UpdateAction``. This method takes care of saving the data of the
|
||||
|
@ -287,7 +287,7 @@ There are also additional actions which are extensions of the basic Action class
|
||||
|
||||
- :class:`~horizon.tables.BatchAction`
|
||||
- :class:`~horizon.tables.DeleteAction`
|
||||
- :class:`~horizon.tables.UpdateAction`
|
||||
- :class:`~horizon.tables.UpdateAction` **DEPRECATED**
|
||||
- :class:`~horizon.tables.FixedFilterAction`
|
||||
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
//TODO(lcastell):Inline edit is deprecated and will be removed in Horizon 12.0
|
||||
horizon.inline_edit = {
|
||||
get_cell_id: function (td_element) {
|
||||
return [
|
||||
|
@ -108,4 +108,5 @@ horizon.addInitFunction(horizon.tabs.init = function () {
|
||||
});
|
||||
});
|
||||
|
||||
//TODO(lcastell):Inline edit is deprecated and will be removed in Horizon 12.0
|
||||
horizon.tabs.addTabLoadFunction(horizon.inline_edit.init);
|
||||
|
@ -961,8 +961,26 @@ class DeleteAction(BatchAction):
|
||||
"""
|
||||
|
||||
|
||||
class Deprecated(type):
|
||||
# TODO(lcastell) Replace class with similar functionality from
|
||||
# oslo_log.versionutils when it's finally added in 11.0
|
||||
def __new__(meta, name, bases, kwargs):
|
||||
cls = super(Deprecated, meta).__new__(meta, name, bases, kwargs)
|
||||
message = ("WARNING:The UpdateAction class defined in module '%s'"
|
||||
" is deprecated as of Newton and may be removed in "
|
||||
"Horizon P (12.0). Class '%s' defined at module '%s' "
|
||||
"shall no longer subclass it.")
|
||||
if name != 'UpdateAction':
|
||||
LOG.warning(message % (UpdateAction.__module__,
|
||||
name,
|
||||
kwargs['__module__']))
|
||||
return cls
|
||||
|
||||
|
||||
@six.add_metaclass(Deprecated)
|
||||
class UpdateAction(object):
|
||||
"""A table action for cell updates by inline editing."""
|
||||
|
||||
name = "update"
|
||||
|
||||
def action(self, request, datum, obj_id, cell_name, new_cell_value):
|
||||
|
@ -16,7 +16,6 @@ from django.template import defaultfilters as filters
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ungettext_lazy
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tables
|
||||
|
||||
@ -95,33 +94,6 @@ class UpdateRow(tables.Row):
|
||||
wrap=True)
|
||||
|
||||
|
||||
class UpdateCell(tables.UpdateAction):
|
||||
policy_rules = (("image", "modify_metadef_namespace"),)
|
||||
|
||||
def update_cell(self, request, datum, namespace_name,
|
||||
cell_name, new_cell_value):
|
||||
# inline update namespace info
|
||||
try:
|
||||
namespace_obj = datum
|
||||
# updating changed value by new value
|
||||
if cell_name == 'public':
|
||||
cell_name = 'visibility'
|
||||
if new_cell_value:
|
||||
new_cell_value = 'public'
|
||||
else:
|
||||
new_cell_value = 'private'
|
||||
setattr(namespace_obj, cell_name, new_cell_value)
|
||||
properties = {cell_name: new_cell_value}
|
||||
glance.metadefs_namespace_update(
|
||||
request,
|
||||
namespace_name,
|
||||
**properties)
|
||||
except Exception:
|
||||
exceptions.handle(request, ignore=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class AdminNamespacesTable(tables.DataTable):
|
||||
display_name = tables.Column(
|
||||
"display_name",
|
||||
@ -143,15 +115,13 @@ class AdminNamespacesTable(tables.DataTable):
|
||||
verbose_name=_("Public"),
|
||||
empty_value=False,
|
||||
form_field=forms.BooleanField(required=False),
|
||||
filters=(filters.yesno, filters.capfirst),
|
||||
update_action=UpdateCell)
|
||||
filters=(filters.yesno, filters.capfirst))
|
||||
protected = tables.Column(
|
||||
"protected",
|
||||
verbose_name=_("Protected"),
|
||||
empty_value=False,
|
||||
form_field=forms.BooleanField(required=False),
|
||||
filters=(filters.yesno, filters.capfirst),
|
||||
update_action=UpdateCell)
|
||||
filters=(filters.yesno, filters.capfirst))
|
||||
|
||||
def get_object_id(self, datum):
|
||||
return datum.namespace
|
||||
|
@ -20,7 +20,6 @@ from horizon import forms
|
||||
from horizon import tables
|
||||
|
||||
from openstack_dashboard.api import cinder
|
||||
from openstack_dashboard import policy
|
||||
|
||||
|
||||
class CreateVolumeType(tables.LinkAction):
|
||||
@ -196,51 +195,14 @@ class UpdateRow(tables.Row):
|
||||
return volume_type
|
||||
|
||||
|
||||
class UpdateCell(tables.UpdateAction):
|
||||
def allowed(self, request, volume_type, cell):
|
||||
return policy.check(
|
||||
("volume_extension", "volume_extension:types_manage"), request)
|
||||
|
||||
def update_cell(self, request, data, volume_type_id,
|
||||
cell_name, new_cell_value):
|
||||
# inline update volume type name and/or description
|
||||
try:
|
||||
vol_type_obj = data
|
||||
# updating changed value by new value
|
||||
setattr(vol_type_obj, cell_name, new_cell_value)
|
||||
name_value = getattr(vol_type_obj, 'name', None)
|
||||
desc_value = getattr(vol_type_obj, 'description', None)
|
||||
public_value = getattr(vol_type_obj, 'public', None)
|
||||
|
||||
cinder.volume_type_update(
|
||||
request,
|
||||
volume_type_id,
|
||||
name=name_value,
|
||||
description=desc_value,
|
||||
is_public=public_value)
|
||||
except Exception as ex:
|
||||
if ex.code and ex.code == 409:
|
||||
error_message = _('New name conflicts with another '
|
||||
'volume type.')
|
||||
else:
|
||||
error_message = _('Unable to update the volume type.')
|
||||
exceptions.handle(request, error_message)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class VolumeTypesTable(tables.DataTable):
|
||||
name = tables.Column("name", verbose_name=_("Name"),
|
||||
form_field=forms.CharField(
|
||||
max_length=64),
|
||||
update_action=UpdateCell)
|
||||
form_field=forms.CharField(max_length=64))
|
||||
description = tables.Column(lambda obj: getattr(obj, 'description', None),
|
||||
verbose_name=_('Description'),
|
||||
form_field=forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': 4}),
|
||||
required=False),
|
||||
update_action=UpdateCell)
|
||||
required=False))
|
||||
|
||||
assoc_qos_spec = tables.Column("associated_qos_spec",
|
||||
verbose_name=_("Associated QoS Spec"))
|
||||
@ -250,7 +212,6 @@ class VolumeTypesTable(tables.DataTable):
|
||||
public = tables.Column("is_public",
|
||||
verbose_name=_("Public"),
|
||||
filters=(filters.yesno, filters.capfirst),
|
||||
update_action=UpdateCell,
|
||||
form_field=forms.BooleanField(
|
||||
label=_('Public'), required=False))
|
||||
|
||||
|
@ -10,17 +10,14 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.core.exceptions import ValidationError # noqa
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.template import defaultfilters as filters
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ungettext_lazy
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tables
|
||||
from keystoneclient.exceptions import Conflict # noqa
|
||||
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard import policy
|
||||
@ -223,58 +220,21 @@ class UpdateRow(tables.Row):
|
||||
return project_info
|
||||
|
||||
|
||||
class UpdateCell(tables.UpdateAction):
|
||||
def allowed(self, request, project, cell):
|
||||
policy_rule = (("identity", "identity:update_project"),)
|
||||
return (
|
||||
(cell.column.name != 'enabled' or
|
||||
request.user.project_id != cell.datum.id) and
|
||||
api.keystone.keystone_can_edit_project() and
|
||||
policy.check(policy_rule, request))
|
||||
|
||||
def update_cell(self, request, datum, project_id,
|
||||
cell_name, new_cell_value):
|
||||
# inline update project info
|
||||
try:
|
||||
project_obj = datum
|
||||
# updating changed value by new value
|
||||
setattr(project_obj, cell_name, new_cell_value)
|
||||
api.keystone.tenant_update(
|
||||
request,
|
||||
project_id,
|
||||
name=project_obj.name,
|
||||
description=project_obj.description,
|
||||
enabled=project_obj.enabled)
|
||||
|
||||
except Conflict:
|
||||
# Returning a nice error message about name conflict. The message
|
||||
# from exception is not that clear for the users.
|
||||
message = _("This name is already taken.")
|
||||
raise ValidationError(message)
|
||||
except Exception:
|
||||
exceptions.handle(request, ignore=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class TenantsTable(tables.DataTable):
|
||||
name = tables.Column('name', verbose_name=_('Name'),
|
||||
link=("horizon:identity:projects:detail"),
|
||||
form_field=forms.CharField(max_length=64),
|
||||
update_action=UpdateCell)
|
||||
form_field=forms.CharField(max_length=64))
|
||||
description = tables.Column(lambda obj: getattr(obj, 'description', None),
|
||||
verbose_name=_('Description'),
|
||||
form_field=forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': 4}),
|
||||
required=False),
|
||||
update_action=UpdateCell)
|
||||
required=False))
|
||||
id = tables.Column('id', verbose_name=_('Project ID'))
|
||||
enabled = tables.Column('enabled', verbose_name=_('Enabled'), status=True,
|
||||
filters=(filters.yesno, filters.capfirst),
|
||||
form_field=forms.BooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False),
|
||||
update_action=UpdateCell)
|
||||
required=False))
|
||||
|
||||
if api.keystone.VERSIONS.active >= 3:
|
||||
domain_name = tables.Column(
|
||||
@ -284,8 +244,7 @@ class TenantsTable(tables.DataTable):
|
||||
filters=(filters.yesno, filters.capfirst),
|
||||
form_field=forms.BooleanField(
|
||||
label=_('Enabled'),
|
||||
required=False),
|
||||
update_action=UpdateCell)
|
||||
required=False))
|
||||
|
||||
def get_project_detail_link(self, project):
|
||||
# this method is an ugly monkey patch, needed because
|
||||
|
@ -12,7 +12,6 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
@ -36,13 +35,6 @@ from openstack_dashboard.test import helpers as test
|
||||
from openstack_dashboard import usage
|
||||
from openstack_dashboard.usage import quotas
|
||||
|
||||
with_sel = os.environ.get('WITH_SELENIUM', False)
|
||||
if with_sel:
|
||||
from selenium.webdriver import ActionChains # noqa
|
||||
from selenium.webdriver.common import keys
|
||||
|
||||
from socket import timeout as socket_timeout # noqa
|
||||
|
||||
|
||||
INDEX_URL = reverse('horizon:identity:projects:index')
|
||||
USER_ROLE_PREFIX = workflows.PROJECT_USER_MEMBER_SLUG + "_role_"
|
||||
@ -1646,143 +1638,6 @@ class DetailProjectViewTests(test.BaseAdminViewTests):
|
||||
@unittest.skipUnless(os.environ.get('WITH_SELENIUM', False),
|
||||
"The WITH_SELENIUM env variable is not set.")
|
||||
class SeleniumTests(test.SeleniumAdminTestCase):
|
||||
@test.create_stubs(
|
||||
{api.keystone: ('tenant_list', 'tenant_get', 'tenant_update',
|
||||
'domain_lookup')})
|
||||
def test_inline_editing_update(self):
|
||||
# Tenant List
|
||||
api.keystone.tenant_list(IgnoreArg(),
|
||||
domain=None,
|
||||
marker=None,
|
||||
paginate=True) \
|
||||
.AndReturn([self.tenants.list(), False])
|
||||
api.keystone.domain_lookup(IgnoreArg()).AndReturn({None: None})
|
||||
# Edit mod
|
||||
api.keystone.tenant_get(IgnoreArg(),
|
||||
u'1',
|
||||
admin=True) \
|
||||
.AndReturn(self.tenants.list()[0])
|
||||
# Update - requires get and update
|
||||
api.keystone.tenant_get(IgnoreArg(),
|
||||
u'1',
|
||||
admin=True) \
|
||||
.AndReturn(self.tenants.list()[0])
|
||||
api.keystone.tenant_update(
|
||||
IgnoreArg(),
|
||||
u'1',
|
||||
description='a test tenant.',
|
||||
enabled=True,
|
||||
name=u'Changed test_tenant')
|
||||
# Refreshing cell with changed name
|
||||
changed_tenant = copy.copy(self.tenants.list()[0])
|
||||
changed_tenant.name = u'Changed test_tenant'
|
||||
api.keystone.tenant_get(IgnoreArg(),
|
||||
u'1',
|
||||
admin=True) \
|
||||
.AndReturn(changed_tenant)
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
self.selenium.get("%s%s" % (self.live_server_url, INDEX_URL))
|
||||
|
||||
# Check the presence of the important elements
|
||||
td_element = self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']")
|
||||
cell_wrapper = td_element.find_element_by_class_name(
|
||||
'table_cell_wrapper')
|
||||
edit_button_wrapper = td_element.find_element_by_class_name(
|
||||
'table_cell_action')
|
||||
edit_button = edit_button_wrapper.find_element_by_tag_name('button')
|
||||
# Hovering over td and clicking on edit button
|
||||
action_chains = ActionChains(self.selenium)
|
||||
action_chains.move_to_element(cell_wrapper).click(edit_button)
|
||||
action_chains.perform()
|
||||
# Waiting for the AJAX response for switching to editing mod
|
||||
wait = self.ui.WebDriverWait(self.selenium, 10,
|
||||
ignored_exceptions=[socket_timeout])
|
||||
wait.until(lambda x: self.selenium.find_element_by_name("name__1"))
|
||||
# Changing project name in cell form
|
||||
td_element = self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']")
|
||||
name_input = td_element.find_element_by_tag_name('input')
|
||||
name_input.send_keys(keys.Keys.HOME)
|
||||
name_input.send_keys("Changed ")
|
||||
# Saving new project name by AJAX
|
||||
td_element.find_element_by_class_name('inline-edit-submit').click()
|
||||
# Waiting for the AJAX response of cell refresh
|
||||
wait = self.ui.WebDriverWait(self.selenium, 10,
|
||||
ignored_exceptions=[socket_timeout])
|
||||
wait.until(lambda x: self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']"
|
||||
"/div[@class='table_cell_wrapper']"
|
||||
"/div[@class='table_cell_data_wrapper']"))
|
||||
# Checking new project name after cell refresh
|
||||
data_wrapper = self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']"
|
||||
"/div[@class='table_cell_wrapper']"
|
||||
"/div[@class='table_cell_data_wrapper']")
|
||||
self.assertTrue(data_wrapper.text == u'Changed test_tenant',
|
||||
"Error: saved tenant name is expected to be "
|
||||
"'Changed test_tenant'")
|
||||
|
||||
@test.create_stubs(
|
||||
{api.keystone: ('tenant_list', 'tenant_get', 'domain_lookup')})
|
||||
def test_inline_editing_cancel(self):
|
||||
# Tenant List
|
||||
api.keystone.tenant_list(IgnoreArg(),
|
||||
domain=None,
|
||||
marker=None,
|
||||
paginate=True) \
|
||||
.AndReturn([self.tenants.list(), False])
|
||||
api.keystone.domain_lookup(IgnoreArg()).AndReturn({None: None})
|
||||
# Edit mod
|
||||
api.keystone.tenant_get(IgnoreArg(),
|
||||
u'1',
|
||||
admin=True) \
|
||||
.AndReturn(self.tenants.list()[0])
|
||||
# Cancel edit mod is without the request
|
||||
|
||||
self.mox.ReplayAll()
|
||||
|
||||
self.selenium.get("%s%s" % (self.live_server_url, INDEX_URL))
|
||||
|
||||
# Check the presence of the important elements
|
||||
td_element = self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']")
|
||||
cell_wrapper = td_element.find_element_by_class_name(
|
||||
'table_cell_wrapper')
|
||||
edit_button_wrapper = td_element.find_element_by_class_name(
|
||||
'table_cell_action')
|
||||
edit_button = edit_button_wrapper.find_element_by_tag_name('button')
|
||||
# Hovering over td and clicking on edit
|
||||
action_chains = ActionChains(self.selenium)
|
||||
action_chains.move_to_element(cell_wrapper).click(edit_button)
|
||||
action_chains.perform()
|
||||
# Waiting for the AJAX response for switching to editing mod
|
||||
wait = self.ui.WebDriverWait(self.selenium, 10,
|
||||
ignored_exceptions=[socket_timeout])
|
||||
wait.until(lambda x: self.selenium.find_element_by_name("name__1"))
|
||||
# Click on cancel button
|
||||
td_element = self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']")
|
||||
td_element.find_element_by_class_name('inline-edit-cancel').click()
|
||||
# Cancel is via javascript, so it should be immediate
|
||||
# Checking that tenant name is not changed
|
||||
data_wrapper = self.selenium.find_element_by_xpath(
|
||||
"//td[@data-update-url='/identity/?action=cell_update"
|
||||
"&table=tenants&cell_name=name&obj_id=1']"
|
||||
"/div[@class='table_cell_wrapper']"
|
||||
"/div[@class='table_cell_data_wrapper']")
|
||||
self.assertTrue(data_wrapper.text == u'test_tenant',
|
||||
"Error: saved tenant name is expected to be "
|
||||
"'test_tenant'")
|
||||
|
||||
@test.create_stubs({api.keystone: ('get_default_domain',
|
||||
'get_default_role',
|
||||
'user_list',
|
||||
|
@ -10,14 +10,11 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.core import exceptions as django_exceptions
|
||||
from django.template import defaultfilters
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ungettext_lazy
|
||||
|
||||
from horizon import exceptions as horizon_exceptions
|
||||
from horizon import forms
|
||||
from horizon import messages
|
||||
from horizon import tables
|
||||
from openstack_dashboard import api
|
||||
from openstack_dashboard import policy
|
||||
@ -176,47 +173,6 @@ class UpdateRow(tables.Row):
|
||||
return user_info
|
||||
|
||||
|
||||
class UpdateCell(tables.UpdateAction):
|
||||
def allowed(self, request, user, cell):
|
||||
return api.keystone.keystone_can_edit_user() and \
|
||||
policy.check((("identity", "identity:update_user"),),
|
||||
request)
|
||||
|
||||
def update_cell(self, request, datum, user_id,
|
||||
cell_name, new_cell_value):
|
||||
try:
|
||||
user_obj = datum
|
||||
setattr(user_obj, cell_name, new_cell_value)
|
||||
if ((not new_cell_value) or new_cell_value.isspace()) and \
|
||||
(cell_name == 'name'):
|
||||
message = _("The User Name field cannot be empty.")
|
||||
messages.warning(request, message)
|
||||
raise django_exceptions.ValidationError(message)
|
||||
kwargs = {}
|
||||
attr_to_keyword_map = {
|
||||
'name': 'name',
|
||||
'description': 'description',
|
||||
'email': 'email',
|
||||
'enabled': 'enabled',
|
||||
'project_id': 'project'
|
||||
}
|
||||
for key in attr_to_keyword_map:
|
||||
value = getattr(user_obj, key, None)
|
||||
keyword_name = attr_to_keyword_map[key]
|
||||
if value is not None:
|
||||
kwargs[keyword_name] = value
|
||||
api.keystone.user_update(request, user_obj, **kwargs)
|
||||
|
||||
except horizon_exceptions.Conflict:
|
||||
message = _("This name is already taken.")
|
||||
messages.warning(request, message)
|
||||
raise django_exceptions.ValidationError(message)
|
||||
except Exception:
|
||||
horizon_exceptions.handle(request, ignore=True)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class UsersTable(tables.DataTable):
|
||||
STATUS_CHOICES = (
|
||||
("true", True),
|
||||
@ -225,19 +181,16 @@ class UsersTable(tables.DataTable):
|
||||
name = tables.Column('name',
|
||||
link="horizon:identity:users:detail",
|
||||
verbose_name=_('User Name'),
|
||||
form_field=forms.CharField(required=False),
|
||||
update_action=UpdateCell)
|
||||
form_field=forms.CharField(required=False))
|
||||
description = tables.Column(lambda obj: getattr(obj, 'description', None),
|
||||
verbose_name=_('Description'),
|
||||
hidden=KEYSTONE_V2_ENABLED,
|
||||
form_field=forms.CharField(
|
||||
widget=forms.Textarea(attrs={'rows': 4}),
|
||||
required=False),
|
||||
update_action=UpdateCell)
|
||||
required=False))
|
||||
email = tables.Column(lambda obj: getattr(obj, 'email', None),
|
||||
verbose_name=_('Email'),
|
||||
form_field=forms.EmailField(required=False),
|
||||
update_action=UpdateCell,
|
||||
filters=(lambda v: defaultfilters
|
||||
.default_if_none(v, ""),
|
||||
defaultfilters.escape,
|
||||
|
@ -0,0 +1,7 @@
|
||||
---
|
||||
deprecations:
|
||||
- Inline Edit functionality for Horizon tables is now deprecated and will be
|
||||
removed in Horizon P (12.0)
|
||||
The functionality was removed from the following tables.
|
||||
Admin Volume Types table, Admin Metadata Definitions table, Identity
|
||||
Projects table and Identity Users table
|
Loading…
Reference in New Issue
Block a user