mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-08-14 04:40:36 +03:00
Implement Schema search
This commit is contained in:
parent
f4af39e62e
commit
a8bbb2b63c
|
@ -15,6 +15,10 @@ from apps.rsform.views import (
|
|||
)
|
||||
|
||||
|
||||
def _response_contains(response, schema: RSForm) -> bool:
|
||||
return any(x for x in response.data if x['id'] == schema.id)
|
||||
|
||||
|
||||
class TestConstituentaAPI(APITestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
|
@ -369,3 +373,29 @@ class TestFunctionalViews(APITestCase):
|
|||
response = parse_expression(request)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIsInstance(response.data['expression'][0], ErrorDetail)
|
||||
|
||||
|
||||
class TestLibraryAPI(APITestCase):
|
||||
def setUp(self):
|
||||
self.factory = APIRequestFactory()
|
||||
self.user = User.objects.create(username='UserTest')
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.rsform_owned: RSForm = RSForm.objects.create(title='Test', alias='T1', owner=self.user)
|
||||
self.rsform_unowned: RSForm = RSForm.objects.create(title='Test2', alias='T2')
|
||||
self.rsform_common: RSForm = RSForm.objects.create(title='Test3', alias='T3', is_common=True)
|
||||
|
||||
def test_retrieve_common(self):
|
||||
self.client.logout()
|
||||
response = self.client.get('/api/library/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(_response_contains(response, self.rsform_common))
|
||||
self.assertFalse(_response_contains(response, self.rsform_unowned))
|
||||
self.assertFalse(_response_contains(response, self.rsform_owned))
|
||||
|
||||
def test_retrieve_owned(self):
|
||||
response = self.client.get('/api/library/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(_response_contains(response, self.rsform_common))
|
||||
self.assertFalse(_response_contains(response, self.rsform_unowned))
|
||||
self.assertTrue(_response_contains(response, self.rsform_owned))
|
||||
|
|
|
@ -7,6 +7,7 @@ rsform_router = routers.SimpleRouter()
|
|||
rsform_router.register(r'rsforms', views.RSFormViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('library/', views.LibraryView.as_view(), name='library'),
|
||||
path('constituents/<int:pk>/', views.ConstituentAPIView.as_view(), name='constituenta-detail'),
|
||||
path('rsforms/import-trs/', views.TrsImportView.as_view()),
|
||||
path('rsforms/create-detailed/', views.create_rsform),
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
from django.http import HttpResponse
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.db.models import Q
|
||||
from rest_framework import views, viewsets, filters, generics, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
@ -12,6 +13,21 @@ from . import serializers
|
|||
from . import utils
|
||||
|
||||
|
||||
class LibraryView(generics.ListAPIView):
|
||||
'''
|
||||
Get list of rsforms available for active user.
|
||||
'''
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
serializer_class = serializers.RSFormSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_anonymous:
|
||||
return models.RSForm.objects.filter(Q(is_common=True) | Q(owner=user))
|
||||
else:
|
||||
return models.RSForm.objects.filter(is_common=True)
|
||||
|
||||
|
||||
class ConstituentAPIView(generics.RetrieveUpdateAPIView):
|
||||
queryset = models.Constituenta.objects.all()
|
||||
serializer_class = serializers.ConstituentaSerializer
|
||||
|
|
|
@ -39,7 +39,7 @@ function Navigation () {
|
|||
<div className='pr-6 pl-2 py-2.5 h-[4rem] flex items-center justify-between border-b-2 clr-nav rounded-none'>
|
||||
<div className='flex items-start justify-start '>
|
||||
<Logo title='КонцептПортал' />
|
||||
<TopSearch placeholder='Поиск схемы...' />
|
||||
<TopSearch />
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
{user && <UserTools/>}
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useNavSearch } from '../../context/NavSearchContext';
|
||||
import { MagnifyingGlassIcon } from '../Icons';
|
||||
|
||||
interface TopSearchProps {
|
||||
placeholder: string
|
||||
}
|
||||
function TopSearch() {
|
||||
const navigate = useNavigate();
|
||||
const { query, setQuery } = useNavSearch();
|
||||
|
||||
function TopSearch({ placeholder }: TopSearchProps) {
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
const url = new URL(window.location.href);
|
||||
if (!url.href.includes('/library')) {
|
||||
event.preventDefault();
|
||||
navigate('/library?filter=query');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action='#' method='GET' className='hidden md:block md:pl-2'>
|
||||
<div className='hidden md:block md:pl-2'>
|
||||
<div className='relative md:w-96'>
|
||||
<div className='absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none'>
|
||||
<MagnifyingGlassIcon />
|
||||
|
@ -15,12 +27,14 @@ function TopSearch({ placeholder }: TopSearchProps) {
|
|||
type='text'
|
||||
name='email'
|
||||
id='topbar-search'
|
||||
value={query}
|
||||
className='text-sm block w-full pl-10 p-2.5 text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-600 dark:border-gray-400 dark:placeholder-gray-200 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500'
|
||||
placeholder={placeholder}
|
||||
// onChange={}
|
||||
placeholder='Поиск схемы...'
|
||||
onChange={data => setQuery(data.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
76
rsconcept/frontend/src/context/LibraryContext.tsx
Normal file
76
rsconcept/frontend/src/context/LibraryContext.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { ErrorInfo } from '../components/BackendError';
|
||||
import { getLibrary } from '../utils/backendAPI';
|
||||
import { ILibraryFilter, IRSFormMeta, matchRSFormMeta } from '../utils/models';
|
||||
|
||||
interface ILibraryContext {
|
||||
items: IRSFormMeta[]
|
||||
loading: boolean
|
||||
error: ErrorInfo
|
||||
setError: (error: ErrorInfo) => void
|
||||
|
||||
reload: () => void
|
||||
filter: (params: ILibraryFilter) => IRSFormMeta[]
|
||||
}
|
||||
|
||||
const LibraryContext = createContext<ILibraryContext | null>(null)
|
||||
export const useLibrary = (): ILibraryContext => {
|
||||
const context = useContext(LibraryContext);
|
||||
if (context == null) {
|
||||
throw new Error(
|
||||
'useLibrary has to be used within <LibraryState.Provider>'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface LibraryStateProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const LibraryState = ({ children }: LibraryStateProps) => {
|
||||
const [items, setItems] = useState<IRSFormMeta[]>([])
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorInfo>(undefined);
|
||||
|
||||
const filter = useCallback(
|
||||
(params: ILibraryFilter) => {
|
||||
let result = items;
|
||||
if (params.ownedBy) {
|
||||
result = result.filter(schema => schema.owner === params.ownedBy);
|
||||
}
|
||||
if (params.is_common !== undefined) {
|
||||
result = result.filter(schema => schema.is_common === params.is_common);
|
||||
}
|
||||
if (params.queryMeta) {
|
||||
result = result.filter(schema => matchRSFormMeta(params.queryMeta!, schema));
|
||||
}
|
||||
return result;
|
||||
}, [items]);
|
||||
|
||||
const reload = useCallback(
|
||||
() => {
|
||||
setItems([]);
|
||||
setError(undefined);
|
||||
getLibrary({
|
||||
setLoading: setLoading,
|
||||
showError: true,
|
||||
onError: (error) => setError(error),
|
||||
onSuccess: newData => { setItems(newData); }
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload])
|
||||
|
||||
return (
|
||||
<LibraryContext.Provider value={{
|
||||
items, loading, error, setError,
|
||||
reload, filter
|
||||
}}>
|
||||
{ children }
|
||||
</LibraryContext.Provider>
|
||||
);
|
||||
}
|
38
rsconcept/frontend/src/context/NavSearchContext.tsx
Normal file
38
rsconcept/frontend/src/context/NavSearchContext.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
|
||||
interface INavSearchContext {
|
||||
query: string
|
||||
setQuery: (value: string) => void
|
||||
cleanQuery: () => void
|
||||
}
|
||||
|
||||
const NavSearchContext = createContext<INavSearchContext | null>(null);
|
||||
export const useNavSearch = () => {
|
||||
const context = useContext(NavSearchContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useNavSearch has to be used within <NavSearchState.Provider>'
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface NavSearchStateProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const NavSearchState = ({ children }: NavSearchStateProps) => {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const cleanQuery = useCallback(() => setQuery(''), []);
|
||||
|
||||
return (
|
||||
<NavSearchContext.Provider value={{
|
||||
query,
|
||||
setQuery,
|
||||
cleanQuery
|
||||
}}>
|
||||
{children}
|
||||
</NavSearchContext.Provider>
|
||||
);
|
||||
}
|
|
@ -45,9 +45,9 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
|
|||
return (
|
||||
<ThemeContext.Provider value={{
|
||||
darkMode,
|
||||
toggleDarkMode: () => { setDarkMode(prev => !prev); },
|
||||
toggleDarkMode: () => setDarkMode(prev => !prev),
|
||||
noNavigation,
|
||||
toggleNoNavigation: () => { setNoNavigation(prev => !prev); }
|
||||
toggleNoNavigation: () => setNoNavigation(prev => !prev)
|
||||
}}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
|
|
|
@ -27,7 +27,7 @@ interface UsersStateProps {
|
|||
export const UsersState = ({ children }: UsersStateProps) => {
|
||||
const [users, setUsers] = useState<IUserInfo[]>([])
|
||||
|
||||
const getUserLabel = (userID: number | null) => {
|
||||
function getUserLabel(userID: number | null) {
|
||||
const user = users.find(({ id }) => id === userID)
|
||||
if (!user) {
|
||||
return (userID ? userID.toString() : 'Отсутствует');
|
||||
|
@ -47,18 +47,17 @@ export const UsersState = ({ children }: UsersStateProps) => {
|
|||
}
|
||||
|
||||
const reload = useCallback(
|
||||
() => {
|
||||
getActiveUsers({
|
||||
showError: true,
|
||||
onError: () => { setUsers([]); },
|
||||
onSuccess: newData => { setUsers(newData); }
|
||||
});
|
||||
}, [setUsers]
|
||||
)
|
||||
() => {
|
||||
getActiveUsers({
|
||||
showError: true,
|
||||
onError: () => { setUsers([]); },
|
||||
onSuccess: newData => { setUsers(newData); }
|
||||
});
|
||||
}, [setUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload])
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<UsersContext.Provider value={{
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { type ErrorInfo } from '../components/BackendError';
|
||||
import { getRSForms } from '../utils/backendAPI';
|
||||
import { IRSFormMeta } from '../utils/models'
|
||||
|
||||
export enum FilterType {
|
||||
PERSONAL = 'personal',
|
||||
COMMON = 'common'
|
||||
}
|
||||
|
||||
export interface RSFormsFilter {
|
||||
type: FilterType
|
||||
data?: number | null
|
||||
}
|
||||
|
||||
export function useRSForms() {
|
||||
const [rsforms, setRSForms] = useState<IRSFormMeta[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<ErrorInfo>(undefined);
|
||||
|
||||
const loadList = useCallback((filter: RSFormsFilter) => {
|
||||
getRSForms(filter, {
|
||||
showError: true,
|
||||
setLoading,
|
||||
onError: error => { setError(error); },
|
||||
onSuccess: newData => { setRSForms(newData); }
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { rsforms, error, loading, loadList };
|
||||
}
|
|
@ -9,6 +9,8 @@ import { BrowserRouter } from 'react-router-dom';
|
|||
import App from './App.tsx'
|
||||
import ErrorFallback from './components/ErrorFallback.tsx';
|
||||
import { AuthState } from './context/AuthContext.tsx';
|
||||
import { LibraryState } from './context/LibraryContext.tsx';
|
||||
import { NavSearchState } from './context/NavSearchContext.tsx';
|
||||
import { ThemeState } from './context/ThemeContext.tsx';
|
||||
import { UsersState } from './context/UsersContext.tsx';
|
||||
import { initBackend } from './utils/backendAPI.ts';
|
||||
|
@ -34,11 +36,17 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||
>
|
||||
<IntlProvider locale='ru' defaultLocale='ru'>
|
||||
<ThemeState>
|
||||
<NavSearchState>
|
||||
<AuthState>
|
||||
<UsersState>
|
||||
<LibraryState>
|
||||
|
||||
<App />
|
||||
|
||||
</LibraryState>
|
||||
</UsersState>
|
||||
</AuthState>
|
||||
</NavSearchState>
|
||||
</ThemeState>
|
||||
</IntlProvider>
|
||||
</ErrorBoundary>
|
||||
|
|
|
@ -1,32 +1,47 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import BackendError from '../../components/BackendError'
|
||||
import { Loader } from '../../components/Common/Loader'
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { FilterType, type RSFormsFilter, useRSForms } from '../../hooks/useRSForms'
|
||||
import { useLibrary } from '../../context/LibraryContext';
|
||||
import { useNavSearch } from '../../context/NavSearchContext';
|
||||
import { ILibraryFilter, IRSFormMeta } from '../../utils/models';
|
||||
import ViewLibrary from './ViewLibrary';
|
||||
|
||||
function LibraryPage() {
|
||||
const search = useLocation().search;
|
||||
const { query, cleanQuery } = useNavSearch();
|
||||
const { user } = useAuth();
|
||||
const { rsforms, error, loading, loadList } = useRSForms();
|
||||
|
||||
useEffect(() => {
|
||||
const filterQuery = new URLSearchParams(search).get('filter');
|
||||
const type = (!user || !filterQuery ? FilterType.COMMON : filterQuery as FilterType);
|
||||
const filter: RSFormsFilter = { type };
|
||||
if (type === FilterType.PERSONAL) {
|
||||
filter.data = user?.id;
|
||||
const library = useLibrary();
|
||||
|
||||
const [ filterParams, setFilterParams ] = useState<ILibraryFilter>({});
|
||||
const [ items, setItems ] = useState<IRSFormMeta[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const filterType = new URLSearchParams(search).get('filter');
|
||||
if (filterType === 'common') {
|
||||
setFilterParams({
|
||||
is_common: true
|
||||
});
|
||||
} else if (filterType === 'personal' && user) {
|
||||
setFilterParams({
|
||||
ownedBy: user.id!
|
||||
});
|
||||
}
|
||||
loadList(filter);
|
||||
}, [search, user, loadList]);
|
||||
}, [user, search, cleanQuery]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const filter = filterParams;
|
||||
filterParams.queryMeta = query ? query: undefined;
|
||||
setItems(library.filter(filter));
|
||||
}, [query, library, filterParams]);
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{ loading && <Loader /> }
|
||||
{ error && <BackendError error={error} />}
|
||||
{ !loading && rsforms && <ViewLibrary schemas={rsforms} /> }
|
||||
{ library.loading && <Loader /> }
|
||||
{ library.error && <BackendError error={library.error} />}
|
||||
{ !library.loading && library.items && <ViewLibrary schemas={items} /> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import axios, { AxiosRequestConfig } from 'axios'
|
|||
import { toast } from 'react-toastify'
|
||||
|
||||
import { type ErrorInfo } from '../components/BackendError'
|
||||
import { FilterType, RSFormsFilter } from '../hooks/useRSForms'
|
||||
import { config } from './constants'
|
||||
import {
|
||||
IConstituentaList, IConstituentaMeta,
|
||||
|
@ -65,7 +64,7 @@ export function getAuth(request: FrontPull<ICurrentUser>) {
|
|||
export function postLogin(request: FrontPush<IUserLoginData>) {
|
||||
AxiosPost({
|
||||
title: 'Login',
|
||||
endpoint: `/users/api/login`,
|
||||
endpoint: '/users/api/login',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
@ -73,7 +72,7 @@ export function postLogin(request: FrontPush<IUserLoginData>) {
|
|||
export function postLogout(request: FrontAction) {
|
||||
AxiosPost({
|
||||
title: 'Logout',
|
||||
endpoint: `/users/api/logout`,
|
||||
endpoint: '/users/api/logout',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
@ -81,7 +80,7 @@ export function postLogout(request: FrontAction) {
|
|||
export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>) {
|
||||
AxiosPost({
|
||||
title: 'Register user',
|
||||
endpoint: `/users/api/signup`,
|
||||
endpoint: '/users/api/signup',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
@ -89,7 +88,7 @@ export function postSignup(request: FrontExchange<IUserSignupData, IUserProfile>
|
|||
export function getProfile(request: FrontPull<IUserProfile>) {
|
||||
AxiosGet({
|
||||
title: 'Current user profile',
|
||||
endpoint: `/users/api/profile`,
|
||||
endpoint: '/users/api/profile',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
@ -97,7 +96,7 @@ export function getProfile(request: FrontPull<IUserProfile>) {
|
|||
export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfile>) {
|
||||
AxiosPatch({
|
||||
title: 'Current user profile',
|
||||
endpoint: `/users/api/profile`,
|
||||
endpoint: '/users/api/profile',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
@ -105,19 +104,15 @@ export function patchProfile(request: FrontExchange<IUserUpdateData, IUserProfil
|
|||
export function getActiveUsers(request: FrontPull<IUserInfo[]>) {
|
||||
AxiosGet({
|
||||
title: 'Active users list',
|
||||
endpoint: `/users/api/active-users`,
|
||||
endpoint: '/users/api/active-users',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
||||
export function getRSForms(filter: RSFormsFilter, request: FrontPull<IRSFormMeta[]>) {
|
||||
const endpoint =
|
||||
filter.type === FilterType.PERSONAL
|
||||
? `/api/rsforms?owner=${filter.data as number}`
|
||||
: `/api/rsforms?is_common=true`;
|
||||
export function getLibrary(request: FrontPull<IRSFormMeta[]>) {
|
||||
AxiosGet({
|
||||
title: 'RSForms list',
|
||||
endpoint: endpoint,
|
||||
title: 'Available RSForms (Library) list',
|
||||
endpoint: '/api/library/',
|
||||
request: request
|
||||
});
|
||||
}
|
||||
|
@ -125,7 +120,7 @@ export function getRSForms(filter: RSFormsFilter, request: FrontPull<IRSFormMeta
|
|||
export function postNewRSForm(request: FrontExchange<IRSFormCreateData, IRSFormMeta>) {
|
||||
AxiosPost({
|
||||
title: 'New RSForm',
|
||||
endpoint: `/api/rsforms/create-detailed/`,
|
||||
endpoint: '/api/rsforms/create-detailed/',
|
||||
request: request,
|
||||
options: {
|
||||
headers: {
|
||||
|
|
|
@ -199,6 +199,13 @@ export interface IRSFormUploadData {
|
|||
fileName: string
|
||||
}
|
||||
|
||||
// ========== Library =====
|
||||
export interface ILibraryFilter {
|
||||
ownedBy?: number
|
||||
is_common?: boolean
|
||||
queryMeta?: string
|
||||
}
|
||||
|
||||
// ================ Misc types ================
|
||||
// Constituenta edit mode
|
||||
export enum EditMode {
|
||||
|
@ -312,13 +319,24 @@ export function matchConstituenta(query: string, target?: IConstituenta) {
|
|||
return false;
|
||||
} else if (target.alias.match(query)) {
|
||||
return true;
|
||||
} else if (target.term?.resolved?.match(query)) {
|
||||
} else if (target.term.resolved.match(query)) {
|
||||
return true;
|
||||
} else if (target.definition?.formal.match(query)) {
|
||||
} else if (target.definition.formal.match(query)) {
|
||||
return true;
|
||||
} else if (target.definition?.text.resolved?.match(query)) {
|
||||
} else if (target.definition.text.resolved.match(query)) {
|
||||
return true;
|
||||
} else if (target.convention?.match(query)) {
|
||||
} else if (target.convention.match(query)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchRSFormMeta(query: string, target: IRSFormMeta) {
|
||||
const queryI = query.toUpperCase();
|
||||
if (target.alias.toUpperCase().match(queryI)) {
|
||||
return true;
|
||||
} else if (target.title.toUpperCase().match(queryI)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
|
|
Loading…
Reference in New Issue
Block a user