Добавлена загрузка/замена/удаление фото сотрудника с автоматическим удалением старых файлов

This commit is contained in:
admin i 2025-11-30 10:50:09 +03:00
parent 750a2c5da8
commit b636e13382
12 changed files with 284 additions and 363 deletions

View File

@ -0,0 +1,98 @@
# Generated by Django 5.2.4 on 2025-11-30 07:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('communications', '0013_messengerintegration_messengeraccount_and_more'),
]
operations = [
# Сначала удаляем индексы, которые зависят от полей
migrations.RemoveIndex(
model_name='messengermessage',
name='communicati_integra_af291e_idx',
),
migrations.RemoveIndex(
model_name='messengermessage',
name='communicati_sender__496a25_idx',
),
migrations.RemoveIndex(
model_name='messengermessage',
name='communicati_status_9d880b_idx',
),
migrations.RemoveIndex(
model_name='messengermessage',
name='communicati_synced__4496a8_idx',
),
# Удаляем unique_together
migrations.AlterUniqueTogether(
name='messengermessage',
unique_together=None,
),
# Теперь удаляем поля
migrations.RemoveField(
model_name='messengermessage',
name='sender_account',
),
migrations.RemoveField(
model_name='messengermessage',
name='recipient_account',
),
migrations.RemoveField(
model_name='messengermessage',
name='integration',
),
migrations.RemoveField(
model_name='messengermessage',
name='internal_message',
),
# Удаляем индексы MessengerAccount
migrations.RemoveIndex(
model_name='messengeraccount',
name='communicati_user_id_acfa5e_idx',
),
migrations.RemoveIndex(
model_name='messengeraccount',
name='communicati_externa_2dfbcf_idx',
),
migrations.RemoveIndex(
model_name='messengeraccount',
name='communicati_phone_n_8df544_idx',
),
# Удаляем unique_together MessengerAccount
migrations.AlterUniqueTogether(
name='messengeraccount',
unique_together=None,
),
# Удаляем поля MessengerAccount
migrations.RemoveField(
model_name='messengeraccount',
name='user',
),
migrations.RemoveField(
model_name='messengeraccount',
name='integration',
),
# Удаляем индексы MessengerIntegration
migrations.RemoveIndex(
model_name='messengerintegration',
name='communicati_messeng_c8457c_idx',
),
migrations.RemoveIndex(
model_name='messengerintegration',
name='communicati_status_7fb887_idx',
),
# Теперь можно удалить модели
migrations.DeleteModel(
name='MessengerMessage',
),
migrations.DeleteModel(
name='MessengerAccount',
),
migrations.DeleteModel(
name='MessengerIntegration',
),
]

View File

@ -22,11 +22,6 @@ urlpatterns = [
name="start_private_chat"
),
# Messenger Views
path("messengers/whatsapp/", views.whatsapp_view, name="whatsapp"),
path("messengers/telegram/", views.telegram_view, name="telegram"),
path("messengers/wechat/", views.wechat_view, name="wechat"),
# API Endpoints
# Chat management
path("api/chat/create/", api_views.create_chat, name="api_create_chat"),

View File

@ -452,38 +452,3 @@ def chat_mark_read(request, pk: int):
return JsonResponse(
{"ok": True, "last_read_at": int(ts.timestamp() * 1000)}
)
# ===== Messenger Views =====
@login_required
def whatsapp_view(request):
"""WhatsApp интеграция"""
from django.shortcuts import render
return render(request, 'communications/messengers/whatsapp.html', {
'messenger_name': 'WhatsApp',
'messenger_icon': 'bi-whatsapp',
'messenger_color': '#25D366'
})
@login_required
def telegram_view(request):
"""Telegram интеграция"""
from django.shortcuts import render
return render(request, 'communications/messengers/telegram.html', {
'messenger_name': 'Telegram',
'messenger_icon': 'bi-telegram',
'messenger_color': '#0088cc'
})
@login_required
def wechat_view(request):
"""WeChat интеграция"""
from django.shortcuts import render
return render(request, 'communications/messengers/wechat.html', {
'messenger_name': 'WeChat',
'messenger_icon': 'bi-wechat',
'messenger_color': '#09B83E'
})

View File

@ -1,4 +1,5 @@
# users/signals.py
import os
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
@ -9,7 +10,32 @@ from bots.models import BotSubscriber
@receiver(pre_save, sender=Employee)
def unbind_bot_subscriber_on_contact_change(sender, instance: Employee, **kwargs):
def delete_old_avatar_on_change(sender, instance: Employee, **kwargs):
"""Удаляем старый файл аватара при замене на новый или при удалении."""
if instance.pk is None:
# Новый пользователь - нечего удалять
return
try:
old = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
return
# Если аватар изменился или удален
if old.avatar and old.avatar != instance.avatar:
# Удаляем старый файл с диска
if os.path.isfile(old.avatar.path):
try:
os.remove(old.avatar.path)
except Exception as e:
# Логируем ошибку, но не прерываем сохранение
print(f"Error deleting old avatar: {e}")
@receiver(pre_save, sender=Employee)
def unbind_bot_subscriber_on_contact_change(
sender, instance: Employee, **kwargs
):
# Игнорируем новых пользователей
if instance.pk is None:
return

View File

@ -187,39 +187,7 @@
color: white;
}
/* Мессенджеры - ссылки */
.chat-type-btn[data-messenger] {
text-decoration: none;
transition: background-color 0.2s ease, transform 0.2s ease;
}
.chat-type-btn[data-messenger]:hover {
background-color: var(--bs-light) !important;
transform: scale(1.05);
}
.chat-type-btn[data-messenger] i {
transition: transform 0.2s ease;
}
.chat-type-btn[data-messenger]:hover i {
transform: scale(1.1);
}
/* Цвета иконок мессенджеров */
.chat-type-btn[data-messenger="whatsapp"] i {
color: #25D366 !important;
}
.chat-type-btn[data-messenger="telegram"] i {
color: #0088cc !important;
}
.chat-type-btn[data-messenger="wechat"] i {
color: #09B83E !important;
}
.chat-type-badge {
.chat-type-btn .badge {
position: absolute;
top: 0.25rem;
right: 0.25rem;

View File

@ -177,20 +177,43 @@ export function initEmployeeForm(options = {}) {
const skills = collectSkillsPayload();
if (skills) payload.skills_ids = skills;
// avatar (Base64) / clear
const avatarFile = document.getElementById('f_avatar')?.files?.[0] || null;
const clearAvatar = form.querySelector('input[name="clear_avatar"]')?.checked;
// avatar (файл через FormData)
const avatarInput = document.getElementById('avatarInput');
const avatarFile = avatarInput?.files?.[0] || null;
let body;
let headers = {
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]')?.value || ''
};
if (avatarFile) {
try {
const dataUrl = await readFileAsDataURL(avatarFile);
payload.avatar = dataUrl; // Base64ImageField ожидает data URL
} catch (_) {}
} else if (clearAvatar) {
payload.avatar = ''; // Очистка
// Если есть файл - используем FormData
const formData = new FormData();
formData.append('avatar', avatarFile);
// Добавляем остальные поля
Object.entries(payload).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => formData.append(key, v));
} else {
formData.append(key, value);
}
});
if (skills) {
skills.forEach(id => formData.append('skills_ids', id));
}
body = formData;
// Не устанавливаем Content-Type для FormData - браузер сам добавит boundary
} else {
// Если файла нет - используем JSON
headers['Content-Type'] = 'application/json';
body = JSON.stringify(payload);
}
// Если нечего отправлять — просто обновим UI
if (Object.keys(payload).length === 0) {
if (Object.keys(payload).length === 0 && !avatarFile) {
setSubmitting(false);
if (window.bootstrap) {
try {
@ -201,16 +224,10 @@ export function initEmployeeForm(options = {}) {
return;
}
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
const resp = await fetch(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(payload),
headers: headers,
body: body,
credentials: 'same-origin'
});

View File

@ -61,23 +61,6 @@
<i class="bi-megaphone fs-4 d-block"></i>
<span class="badge rounded-pill bg-secondary" data-count="announcement">0</span>
</button>
{# Разделитель #}
<div class="list-group-item p-0" style="height: 1px; background-color: var(--bs-border-color);"></div>
{# Мессенджеры #}
<a href="{% url 'communications:whatsapp' %}" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
title="WhatsApp" data-messenger="whatsapp">
<i class="bi-whatsapp fs-4 d-block text-success"></i>
</a>
<a href="{% url 'communications:telegram' %}" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
title="Telegram" data-messenger="telegram">
<i class="bi-telegram fs-4 d-block text-primary"></i>
</a>
<a href="{% url 'communications:wechat' %}" class="list-group-item list-group-item-action text-center py-3 chat-type-btn"
title="WeChat" data-messenger="wechat">
<i class="bi-wechat fs-4 d-block text-success"></i>
</a>
</div>
</div>
</div>

View File

@ -1,80 +0,0 @@
{# backend/templates/communications/messengers/telegram.html #}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ messenger_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<style>
.messenger-iframe-container {
position: relative;
width: 100%;
height: calc(100vh - 180px);
min-height: 600px;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
background: #fff;
}
.messenger-iframe {
width: 100%;
height: 100%;
border: none;
}
.messenger-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 1;
}
.messenger-loading i {
font-size: 3rem;
color: {{ messenger_color }};
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row">
<div class="col-12">
{% include "includes/components/page_header.html" with icon=messenger_icon title=messenger_name %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="messenger-iframe-container">
<div class="messenger-loading">
<i class="bi-arrow-repeat"></i>
<p class="mt-2 text-muted">Загрузка {{ messenger_name }}...</p>
</div>
<iframe
class="messenger-iframe"
src="https://web.telegram.org/"
allow="microphone; camera"
onload="document.querySelector('.messenger-loading').style.display='none'">
</iframe>
</div>
</div>
</div>
<div class="mt-3">
<a href="{% url 'communications:chat_list' %}" class="btn btn-outline-secondary">
<i class="bi-arrow-left"></i> Вернуться к чатам
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,94 +0,0 @@
{# backend/templates/communications/messengers/wechat.html #}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ messenger_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<style>
.messenger-iframe-container {
position: relative;
width: 100%;
height: calc(100vh - 180px);
min-height: 600px;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
background: #fff;
}
.messenger-iframe {
width: 100%;
height: 100%;
border: none;
}
.messenger-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 1;
}
.messenger-loading i {
font-size: 3rem;
color: {{ messenger_color }};
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.wechat-notice {
padding: 1rem;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row">
<div class="col-12">
{% include "includes/components/page_header.html" with icon=messenger_icon title=messenger_name %}
<div class="wechat-notice">
<i class="bi-info-circle text-warning"></i>
<strong>Внимание:</strong> WeChat не имеет полноценной веб-версии.
Используйте <a href="https://www.wechat.com/en/" target="_blank">официальное приложение</a>.
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="messenger-iframe-container">
<div class="messenger-loading">
<i class="bi-arrow-repeat"></i>
<p class="mt-2 text-muted">Загрузка {{ messenger_name }}...</p>
</div>
<iframe
class="messenger-iframe"
src="https://www.wechat.com/"
allow="microphone; camera"
onload="document.querySelector('.messenger-loading').style.display='none'">
</iframe>
</div>
</div>
</div>
<div class="mt-3">
<a href="{% url 'communications:chat_list' %}" class="btn btn-outline-secondary">
<i class="bi-arrow-left"></i> Вернуться к чатам
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,80 +0,0 @@
{# backend/templates/communications/messengers/whatsapp.html #}
{% extends "base.html" %}
{% load static %}
{% block title %}{{ messenger_name }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/page-header.css' %}">
<style>
.messenger-iframe-container {
position: relative;
width: 100%;
height: calc(100vh - 180px);
min-height: 600px;
border: 1px solid var(--bs-border-color);
border-radius: 0.5rem;
overflow: hidden;
background: #fff;
}
.messenger-iframe {
width: 100%;
height: 100%;
border: none;
}
.messenger-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
z-index: 1;
}
.messenger-loading i {
font-size: 3rem;
color: {{ messenger_color }};
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row">
<div class="col-12">
{% include "includes/components/page_header.html" with icon=messenger_icon title=messenger_name %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="messenger-iframe-container">
<div class="messenger-loading">
<i class="bi-arrow-repeat"></i>
<p class="mt-2 text-muted">Загрузка {{ messenger_name }}...</p>
</div>
<iframe
class="messenger-iframe"
src="https://web.whatsapp.com/"
allow="microphone; camera"
onload="document.querySelector('.messenger-loading').style.display='none'">
</iframe>
</div>
</div>
</div>
<div class="mt-3">
<a href="{% url 'communications:chat_list' %}" class="btn btn-outline-secondary">
<i class="bi-arrow-left"></i> Вернуться к чатам
</a>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -9,6 +9,33 @@
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
{# Фотография #}
<div class="col-12">
<label class="form-label">Фотография</label>
<div class="d-flex align-items-start gap-3">
<div class="position-relative">
{% if emp.avatar %}
<img id="avatarPreview" src="{{ emp.avatar.url }}" alt="Фото"
class="rounded" style="width: 120px; height: 120px; object-fit: cover; border: 2px solid #dee2e6;">
{% else %}
<div id="avatarPreview" class="rounded d-flex align-items-center justify-content-center bg-light text-muted"
style="width: 120px; height: 120px; border: 2px solid #dee2e6;">
<i class="bi-person fs-1"></i>
</div>
{% endif %}
</div>
<div class="flex-grow-1">
<input type="file" class="form-control mb-2" id="avatarInput" name="avatar" accept="image/*">
<small class="text-muted d-block">Форматы: JPG, PNG, GIF. Максимум 5 МБ.</small>
{% if emp.avatar %}
<button type="button" class="btn btn-sm btn-outline-danger mt-2" id="removeAvatarBtn">
<i class="bi-trash"></i> Удалить фото
</button>
{% endif %}
</div>
</div>
</div>
<div class="col-sm-6">
<label class="form-label" for="f_first_name">Имя</label>
<input id="f_first_name" class="form-control" name="first_name"

View File

@ -27,5 +27,101 @@
if (groupPicker) window.groupPicker = groupPicker;
if (editGroupPicker) window.editGroupPicker = editGroupPicker;
if (groupsMgr) window.employeeGroupsManager = groupsMgr;
// Инициализация загрузчика аватара
initAvatarUploader();
}
// Обработка загрузки и предпросмотра аватара
function initAvatarUploader() {
const avatarInput = document.getElementById('avatarInput');
const avatarPreview = document.getElementById('avatarPreview');
const removeBtn = document.getElementById('removeAvatarBtn');
if (!avatarInput || !avatarPreview) return;
// Предпросмотр при выборе файла
avatarInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
// Проверка размера (5 МБ)
if (file.size > 5 * 1024 * 1024) {
alert('Файл слишком большой. Максимум 5 МБ.');
avatarInput.value = '';
return;
}
// Проверка типа
if (!file.type.match(/^image\/(jpeg|jpg|png|gif)$/i)) {
alert('Неподдерживаемый формат. Используйте JPG, PNG или GIF.');
avatarInput.value = '';
return;
}
// Показываем предпросмотр
const reader = new FileReader();
reader.onload = (event) => {
if (avatarPreview.tagName === 'IMG') {
avatarPreview.src = event.target.result;
} else {
// Заменяем placeholder на img
const img = document.createElement('img');
img.id = 'avatarPreview';
img.src = event.target.result;
img.alt = 'Фото';
img.className = 'rounded';
img.style.cssText = 'width: 120px; height: 120px; object-fit: cover; border: 2px solid #dee2e6;';
avatarPreview.replaceWith(img);
}
};
reader.readAsDataURL(file);
});
// Удаление фото
if (removeBtn) {
removeBtn.addEventListener('click', async () => {
if (!confirm('Удалить фотографию?')) return;
try {
const form = document.getElementById('apiEditForm');
const endpoint = form?.dataset?.endpoint;
if (!endpoint) {
alert('Ошибка: не указан endpoint');
return;
}
const response = await fetch(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || ''
},
body: JSON.stringify({ avatar: null })
});
if (response.ok) {
// Заменяем изображение на placeholder
const placeholder = document.createElement('div');
placeholder.id = 'avatarPreview';
placeholder.className = 'rounded d-flex align-items-center justify-content-center bg-light text-muted';
placeholder.style.cssText = 'width: 120px; height: 120px; border: 2px solid #dee2e6;';
placeholder.innerHTML = '<i class="bi-person fs-1"></i>';
document.getElementById('avatarPreview').replaceWith(placeholder);
removeBtn.remove();
avatarInput.value = '';
alert('Фотография удалена');
} else {
const data = await response.json();
alert('Ошибка: ' + (data.error || 'Не удалось удалить фото'));
}
} catch (err) {
console.error('Avatar remove error:', err);
alert('Ошибка при удалении фото');
}
});
}
}
</script>