Добавлена поддержка HEIC/HEIF фото с iPhone: подключен pillow-heif, расширен валидатор форматов, увеличен лимит размера до 20MB
This commit is contained in:
@@ -10,3 +10,13 @@ class ProductsConfig(AppConfig):
|
|||||||
Подключаем сигналы при готовности приложения.
|
Подключаем сигналы при готовности приложения.
|
||||||
"""
|
"""
|
||||||
import products.signals # noqa
|
import products.signals # noqa
|
||||||
|
|
||||||
|
# Регистрация декодеров HEIF/AVIF для Pillow (поддержка HEIC/HEIF/AVIF с iPhone и других устройств)
|
||||||
|
try:
|
||||||
|
from pillow_heif import register_heif_opener, register_avif_opener
|
||||||
|
register_heif_opener()
|
||||||
|
register_avif_opener()
|
||||||
|
except ImportError:
|
||||||
|
# Плагин может отсутствовать в окружении — не ломаем запуск приложения
|
||||||
|
# HEIC/HEIF/AVIF тогда не будут поддерживаться до установки зависимостей
|
||||||
|
pass
|
||||||
|
|||||||
@@ -105,6 +105,20 @@
|
|||||||
border-left: 3px solid #0d6efd !important;
|
border-left: 3px solid #0d6efd !important;
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для карточек фото */
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card .card {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card:hover .card {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -138,7 +152,254 @@
|
|||||||
<div class="text-danger">{{ form.name.errors }}</div>
|
<div class="text-danger">{{ form.name.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Блок: Фотографии -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-gradient" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||||
|
<h5 class="mb-0 text-white">
|
||||||
|
<i class="bi bi-images"></i> Фотографии товара
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Существующие фотографии (только при редактировании) -->
|
||||||
|
{% if object and product_photos %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0 text-secondary">
|
||||||
|
<i class="bi bi-collection"></i> Текущие фотографии
|
||||||
|
<span class="badge bg-primary rounded-pill" id="photos-count">{{ photos_count }}</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="delete-selected-btn" class="btn btn-danger btn-sm shadow-sm" style="display: none;">
|
||||||
|
<i class="bi bi-trash"></i> Удалить (<span id="selected-count">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3" id="photos-grid">
|
||||||
|
{% for photo in product_photos %}
|
||||||
|
<div class="col-lg-3 col-md-4 col-sm-6 photo-card" data-photo-id="{{ photo.pk }}">
|
||||||
|
<div class="card h-100 border-0 shadow-sm hover-lift" style="transition: all 0.3s ease;">
|
||||||
|
<!-- Чекбокс для выбора -->
|
||||||
|
<div class="position-absolute" style="top: 8px; left: 8px; z-index: 10;">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input photo-checkbox shadow-sm"
|
||||||
|
data-photo-id="{{ photo.pk }}"
|
||||||
|
id="photo-check-{{ photo.pk }}"
|
||||||
|
style="width: 22px; height: 22px; cursor: pointer; border-width: 2px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Бейдж главного фото -->
|
||||||
|
{% if photo.order == 0 %}
|
||||||
|
<div class="position-absolute" style="top: 8px; right: 8px; z-index: 10;">
|
||||||
|
<span class="badge bg-success shadow-sm">
|
||||||
|
<i class="bi bi-star-fill"></i> Главное
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Кликабельное фото -->
|
||||||
|
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#photoModal{{ photo.pk }}"
|
||||||
|
style="cursor: pointer;">
|
||||||
|
<img src="{{ photo.image.url }}"
|
||||||
|
alt="Фото товара"
|
||||||
|
class="object-fit-contain p-2"
|
||||||
|
style="transition: transform 0.3s ease;"
|
||||||
|
onmouseover="this.style.transform='scale(1.05)'"
|
||||||
|
onmouseout="this.style.transform='scale(1)'">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-2 bg-white">
|
||||||
|
<!-- Кнопка "Сделать главным" -->
|
||||||
|
{% if photo.order != 0 %}
|
||||||
|
<a href="{% url 'products:product-photo-set-main' photo.pk %}"
|
||||||
|
class="btn btn-outline-warning btn-sm w-100 mb-2"
|
||||||
|
title="Сделать главным">
|
||||||
|
<i class="bi bi-star"></i> Сделать главным
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Кнопки перемещения -->
|
||||||
|
<div class="btn-group w-100 mb-2" role="group">
|
||||||
|
<a href="{% url 'products:product-photo-move-up' photo.pk %}"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
title="Переместить вверх">
|
||||||
|
<i class="bi bi-arrow-up"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:product-photo-move-down' photo.pk %}"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
title="Переместить вниз">
|
||||||
|
<i class="bi bi-arrow-down"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопка удаления -->
|
||||||
|
<a href="{% url 'products:product-photo-delete' photo.pk %}"
|
||||||
|
class="btn btn-outline-danger btn-sm w-100"
|
||||||
|
onclick="return confirm('Удалить это фото?');">
|
||||||
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer bg-light text-center py-1">
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-hash"></i> Позиция: {{ photo.order|add:1 }}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для просмотра фото -->
|
||||||
|
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-labelledby="photoModalLabel{{ photo.pk }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||||
|
<div class="modal-content border-0 shadow">
|
||||||
|
<div class="modal-header bg-dark text-white">
|
||||||
|
<h5 class="modal-title" id="photoModalLabel{{ photo.pk }}">
|
||||||
|
<i class="bi bi-image"></i> Фото товара
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center bg-dark">
|
||||||
|
<img src="{{ photo.image.url }}" class="img-fluid rounded" alt="Фото товара" style="max-height: 75vh;">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||||
|
<i class="bi bi-x-circle"></i> Закрыть
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'products:product-photo-delete' photo.pk %}"
|
||||||
|
class="btn btn-danger"
|
||||||
|
onclick="return confirm('Удалить это фото?');">
|
||||||
|
<i class="bi bi-trash"></i> Удалить фото
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript для массового удаления фотографий -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const deleteBtn = document.getElementById('delete-selected-btn');
|
||||||
|
const selectedCount = document.getElementById('selected-count');
|
||||||
|
const photosCount = document.getElementById('photos-count');
|
||||||
|
const checkboxes = document.querySelectorAll('.photo-checkbox');
|
||||||
|
|
||||||
|
// Обновляем счётчик выбранных и видимость кнопки
|
||||||
|
function updateUI() {
|
||||||
|
const checked = document.querySelectorAll('.photo-checkbox:checked').length;
|
||||||
|
selectedCount.textContent = checked;
|
||||||
|
deleteBtn.style.display = checked > 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик для каждого чекбокса
|
||||||
|
checkboxes.forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', updateUI);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик кнопки удаления
|
||||||
|
deleteBtn.addEventListener('click', function() {
|
||||||
|
const checked = document.querySelectorAll('.photo-checkbox:checked');
|
||||||
|
if (checked.length === 0) return;
|
||||||
|
|
||||||
|
const photoIds = Array.from(checked).map(cb => cb.dataset.photoId);
|
||||||
|
const count = photoIds.length;
|
||||||
|
|
||||||
|
if (!confirm(`Вы уверены, что хотите удалить ${count} фото?`)) return;
|
||||||
|
|
||||||
|
// Отключаем кнопку на время операции
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
deleteBtn.innerHTML = '⏳ Удаление...';
|
||||||
|
|
||||||
|
// Отправляем запрос на сервер
|
||||||
|
fetch('{% url "products:product-photos-delete-bulk" %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ photo_ids: photoIds })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Удаляем карточки фотографий из DOM
|
||||||
|
photoIds.forEach(photoId => {
|
||||||
|
const card = document.querySelector(`[data-photo-id="${photoId}"]`);
|
||||||
|
if (card) card.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем счётчик фотографий
|
||||||
|
const newCount = parseInt(photosCount.textContent) - count;
|
||||||
|
photosCount.textContent = newCount;
|
||||||
|
|
||||||
|
// Скрываем блок если фотографий больше нет
|
||||||
|
if (newCount === 0) {
|
||||||
|
document.querySelector('[id="photos-count"]').closest('.mb-3').parentElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем сообщение об успехе
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||||
|
alert.innerHTML = `✓ ${data.deleted} фото успешно удалено!
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||||
|
document.querySelector('.mb-4.p-3.bg-light.rounded').insertAdjacentElement('beforebegin', alert);
|
||||||
|
|
||||||
|
// Скрываем кнопку
|
||||||
|
deleteBtn.style.display = 'none';
|
||||||
|
selectedCount.textContent = '0';
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Неизвестная ошибка');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert('Ошибка при удалении: ' + error.message);
|
||||||
|
console.error(error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.innerHTML = '🗑️ Удалить отмеченные (<span id="selected-count">' + selectedCount.textContent + '</span>)';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Поле для загрузки новых фотографий -->
|
||||||
|
<div class="alert alert-info border-0 shadow-sm mb-0">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="bi bi-cloud-upload fs-4 me-2"></i>
|
||||||
|
<label for="id_photos" class="form-label fw-bold mb-0">
|
||||||
|
{% if object %}
|
||||||
|
<i class="bi bi-plus-circle"></i> Добавить новые фото
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-upload"></i> Загрузить фото
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-lg shadow-sm" id="id_photos">
|
||||||
|
<small class="form-text text-muted d-block mt-2">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
{% if object %}
|
||||||
|
Выберите фото для добавления к товару (можно выбрать несколько, до 10 штук всего)
|
||||||
|
{% else %}
|
||||||
|
Выберите фото для товара (можно выбрать несколько, до 10 штук)
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<!-- Блок 1: Основная информация (продолжение) -->
|
||||||
|
<div class="mb-4">
|
||||||
<!-- Основная цена и Цена со скидкой -->
|
<!-- Основная цена и Цена со скидкой -->
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
@@ -223,7 +484,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="id_short_description" class="form-label">Краткое описание</label>
|
<label for="id_short_description" class="form-label">Краткое описание</label>
|
||||||
{{ form.short_description }}
|
{{ form.short_description }}
|
||||||
<small class="form-text text-muted">Используется для карточек товаров, превью и площадок</small>
|
<small class="form-text text-muted">Может использоваться для карточек товаров на сайте</small>
|
||||||
{% if form.short_description.errors %}
|
{% if form.short_description.errors %}
|
||||||
<div class="text-danger">{{ form.short_description.errors }}</div>
|
<div class="text-danger">{{ form.short_description.errors }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -471,12 +732,20 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Поле для загрузки новых фотографий -->
|
<!-- Поле для загрузки новых фотографий -->
|
||||||
<div class="mb-0">
|
<div class="alert alert-info border-0 shadow-sm mb-0">
|
||||||
<label for="id_photos" class="form-label fw-bold">
|
<div class="d-flex align-items-center mb-2">
|
||||||
{% if object %}Добавить новые фото{% else %}Загрузить фото{% endif %}
|
<i class="bi bi-cloud-upload fs-4 me-2"></i>
|
||||||
|
<label for="id_photos" class="form-label fw-bold mb-0">
|
||||||
|
{% if object %}
|
||||||
|
<i class="bi bi-plus-circle"></i> Добавить новые фото
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-upload"></i> Загрузить фото
|
||||||
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
|
</div>
|
||||||
<small class="form-text text-muted">
|
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-lg shadow-sm" id="id_photos">
|
||||||
|
<small class="form-text text-muted d-block mt-2">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
{% if object %}
|
{% if object %}
|
||||||
Выберите фото для добавления к товару (можно выбрать несколько, до 10 штук всего)
|
Выберите фото для добавления к товару (можно выбрать несколько, до 10 штук всего)
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -491,11 +760,6 @@
|
|||||||
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
|
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'products:products-list' %}" class="btn btn-secondary">Отмена</a>
|
<a href="{% url 'products:products-list' %}" class="btn btn-secondary">Отмена</a>
|
||||||
{% if perms.products.add_productkit %}
|
|
||||||
<a href="{% url 'products:productkit-create' %}" class="btn btn-outline-primary">
|
|
||||||
<i class="bi bi-box-seam"></i> Создать комплект
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">{% if object %}Сохранить изменения{% else %}Создать товар{% endif %}</button>
|
<button type="submit" class="btn btn-primary">{% if object %}Сохранить изменения{% else %}Создать товар{% endif %}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ def validate_photo(photo):
|
|||||||
Валидация загружаемого фото.
|
Валидация загружаемого фото.
|
||||||
Возвращает (True, None) если валидно, или (False, error_message) если ошибка.
|
Возвращает (True, None) если валидно, или (False, error_message) если ошибка.
|
||||||
"""
|
"""
|
||||||
max_size = 5 * 1024 * 1024 # 5MB
|
max_size = 20 * 1024 * 1024 # 20MB
|
||||||
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif']
|
||||||
|
|
||||||
if photo.size > max_size:
|
if photo.size > max_size:
|
||||||
return False, f'Размер файла {photo.name} превышает 5MB.'
|
return False, f'Размер файла {photo.name} превышает 20MB.'
|
||||||
|
|
||||||
ext = os.path.splitext(photo.name)[1].lower()
|
ext = os.path.splitext(photo.name)[1].lower()
|
||||||
if ext not in allowed_extensions:
|
if ext not in allowed_extensions:
|
||||||
@@ -43,8 +43,8 @@ def handle_photos(request, parent_obj, photo_model, parent_field_name):
|
|||||||
if not photos:
|
if not photos:
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
# МАКСИМУМ 10 ФОТО на товар/комплект/категорию
|
# МАКСИМУМ 5 ФОТО на товар/комплект/категорию
|
||||||
MAX_PHOTOS = 10
|
MAX_PHOTOS = 5
|
||||||
|
|
||||||
# Получаем количество уже существующих фото
|
# Получаем количество уже существующих фото
|
||||||
filter_kwargs = {parent_field_name: parent_obj}
|
filter_kwargs = {parent_field_name: parent_obj}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ django-tenants==3.7.0
|
|||||||
kombu==5.6.0
|
kombu==5.6.0
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
phonenumbers==9.0.17
|
phonenumbers==9.0.17
|
||||||
pillow==11.0.0
|
pillow>=12.0.0
|
||||||
|
pillow-heif>=0.15.0
|
||||||
prompt_toolkit==3.0.52
|
prompt_toolkit==3.0.52
|
||||||
psycopg2-binary==2.9.11
|
psycopg2-binary==2.9.11
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
|
amqp==5.3.1
|
||||||
|
asgiref==3.9.0
|
||||||
|
billiard==4.2.2
|
||||||
|
celery==5.4.0
|
||||||
|
click==8.3.0
|
||||||
|
click-didyoumean==0.3.1
|
||||||
|
click-plugins==1.1.1.2
|
||||||
|
click-repl==0.3.0
|
||||||
|
colorama==0.4.6
|
||||||
Django==5.0.10
|
Django==5.0.10
|
||||||
django-tenants==3.7.0
|
django-celery-results==2.5.1
|
||||||
|
django-environ==0.12.0
|
||||||
django-filter==24.3
|
django-filter==24.3
|
||||||
django-simple-history==3.10.1
|
|
||||||
django-nested-admin==4.1.5
|
django-nested-admin==4.1.5
|
||||||
django-phonenumber-field==8.3.0
|
django-phonenumber-field==8.3.0
|
||||||
django-environ==0.12.0
|
django-simple-history==3.10.1
|
||||||
psycopg2-binary==2.9.11
|
django-tenants==3.7.0
|
||||||
Pillow==11.0.0
|
kombu==5.6.0
|
||||||
|
packaging==25.0
|
||||||
phonenumbers==9.0.17
|
phonenumbers==9.0.17
|
||||||
|
pillow>=12.0.0
|
||||||
|
pillow-heif>=0.15.0
|
||||||
|
prompt_toolkit==3.0.52
|
||||||
|
psycopg2-binary==2.9.11
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-monkey-business==1.1.0
|
||||||
|
redis==5.0.8
|
||||||
|
six==1.17.0
|
||||||
|
sqlparse==0.5.3
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
tzdata==2025.2
|
||||||
Unidecode==1.4.0
|
Unidecode==1.4.0
|
||||||
|
vine==5.1.0
|
||||||
|
wcwidth==0.2.14
|
||||||
|
|||||||
Reference in New Issue
Block a user