Фикс: корректный placeholder для фото сотрудника при пустом avatar, форма отправляет POST на фронтовую вьюху, убрана JS-валидация и кастомный submit.

This commit is contained in:
admin i 2025-12-01 15:44:02 +03:00
parent cdef98adef
commit f5e615f2fc
4 changed files with 171 additions and 313 deletions

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import base64
import json
import mimetypes
from io import BytesIO
from typing import Any, Dict, Tuple
from api.client import get_api_client
@ -16,6 +17,7 @@ from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_http_methods, require_POST
from django.http.multipartparser import MultiPartParser, MultiPartParserError
from .forms_front import DepartmentEditForm, SetHeadForm
@ -45,6 +47,41 @@ def _file_to_data_uri(uploaded) -> str:
return f"data:{mime};base64,{b64}"
def _parse_multipart_request(
request: HttpRequest, logger
) -> tuple[Dict[str, Any], Any]:
"""Возвращает (POST, FILES) даже для PATCH с multipart/form-data."""
content_type = request.content_type or ""
if request.method == "POST" or "multipart/form-data" not in content_type:
return request.POST, request.FILES
try:
stream = BytesIO(request.body)
parser = MultiPartParser(
request.META,
stream,
request.upload_handlers,
request.encoding,
)
post_data, files_data = parser.parse()
logger.info(
"[multipart_parser] Parsed PATCH payload: %s fields, %s files",
len(post_data.keys()),
len(files_data.keys()),
)
return post_data, files_data
except MultiPartParserError as exc:
logger.error(
"[multipart_parser] Failed to parse multipart body: %s",
exc,
)
except Exception:
logger.exception(
"[multipart_parser] Unexpected error while parsing multipart body"
)
return request.POST, request.FILES
def _extract_items(payload: Any):
"""
Универсальный парсер: пагинированный ответ DRF или «сырой» список.
@ -444,8 +481,15 @@ def employee_edit(request, pk: int):
# Проверяем Content-Type
content_type = request.content_type or ''
logger.info(f"[employee_edit] pk={pk}, Content-Type: {content_type}")
logger.info(f"[employee_edit] request.POST keys: {list(request.POST.keys())}")
logger.info(f"[employee_edit] request.FILES keys: {list(request.FILES.keys())}")
parsed_post, parsed_files = _parse_multipart_request(request, logger)
logger.info(
"[employee_edit] request.POST keys: %s",
list(parsed_post.keys()),
)
logger.info(
"[employee_edit] request.FILES keys: %s",
list(parsed_files.keys()),
)
if 'multipart/form-data' in content_type:
# Обрабатываем multipart/form-data (файлы)
@ -453,21 +497,28 @@ def employee_edit(request, pk: int):
payload = {}
# Собираем обычные поля из POST
for key, value in request.POST.items():
for key, value in parsed_post.items():
if key == 'csrfmiddlewaretoken':
continue
# Если это массив (например skills_ids)
if key.endswith('_ids') or key == 'skills':
payload[key] = request.POST.getlist(key)
payload[key] = parsed_post.getlist(key)
else:
payload[key] = value
logger.info(f"[employee_edit] Payload before avatar: {list(payload.keys())}")
logger.info(
"[employee_edit] Payload before avatar: %s",
list(payload.keys()),
)
# Обрабатываем файлы (аватар)
if 'avatar' in request.FILES:
avatar_file = request.FILES['avatar']
logger.info(f"[employee_edit] Processing avatar: {avatar_file.name}, size: {avatar_file.size}")
if 'avatar' in parsed_files:
avatar_file = parsed_files['avatar']
logger.info(
"[employee_edit] Processing avatar: %s, size: %s",
avatar_file.name,
avatar_file.size,
)
# Конвертируем в data URI для Base64ImageField
data_uri = _file_to_data_uri(avatar_file)
payload['avatar'] = data_uri
@ -494,7 +545,30 @@ def employee_edit(request, pk: int):
data = resp.json
except Exception:
data = {"detail": resp.text}
return JsonResponse(data or {}, status=resp.status)
accepts = (request.headers.get("Accept") or "").lower()
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
wants_json = "application/json" in accepts or is_ajax
if wants_json:
return JsonResponse(data or {}, status=resp.status)
if resp.ok:
messages.success(request, "Профиль сотрудника обновлён")
else:
detail = ""
if isinstance(data, dict):
detail = data.get("detail") or data.get("error") or ""
messages.error(
request,
detail or f"Не удалось обновить сотрудника (HTTP {resp.status})",
)
redirect_url = request.META.get("HTTP_REFERER") or reverse(
"employees:employee_profile",
args=(pk,),
)
return redirect(redirect_url)
# ---------- EDIT ME ----------
@ -517,8 +591,15 @@ def employee_edit_me(request):
# Проверяем Content-Type
content_type = request.content_type or ''
logger.info(f"[employee_edit_me] Content-Type: {content_type}")
logger.info(f"[employee_edit_me] request.POST keys: {list(request.POST.keys())}")
logger.info(f"[employee_edit_me] request.FILES keys: {list(request.FILES.keys())}")
parsed_post, parsed_files = _parse_multipart_request(request, logger)
logger.info(
"[employee_edit_me] request.POST keys: %s",
list(parsed_post.keys()),
)
logger.info(
"[employee_edit_me] request.FILES keys: %s",
list(parsed_files.keys()),
)
if 'multipart/form-data' in content_type:
# Обрабатываем multipart/form-data (файлы)
@ -526,29 +607,43 @@ def employee_edit_me(request):
payload = {}
# Собираем обычные поля из POST
for key, value in request.POST.items():
for key, value in parsed_post.items():
if key == 'csrfmiddlewaretoken':
continue
# Если это массив (например skills_ids)
if key.endswith('_ids') or key == 'skills':
payload[key] = request.POST.getlist(key)
payload[key] = parsed_post.getlist(key)
else:
payload[key] = value
logger.info(f"[employee_edit_me] Payload before avatar: {list(payload.keys())}")
logger.info(
"[employee_edit_me] Payload before avatar: %s",
list(payload.keys()),
)
# Обрабатываем файлы (аватар)
if 'avatar' in request.FILES:
avatar_file = request.FILES['avatar']
logger.info(f"[employee_edit_me] Processing avatar: {avatar_file.name}, size: {avatar_file.size}, content_type: {avatar_file.content_type}")
if 'avatar' in parsed_files:
avatar_file = parsed_files['avatar']
logger.info(
"[employee_edit_me] Processing avatar: %s, size: %s, content_type: %s",
avatar_file.name,
avatar_file.size,
avatar_file.content_type,
)
# Конвертируем в data URI для Base64ImageField
data_uri = _file_to_data_uri(avatar_file)
payload['avatar'] = data_uri
logger.info(f"[employee_edit_me] Avatar converted to data URI, length: {len(data_uri)}")
logger.info(
"[employee_edit_me] Avatar converted to data URI, length: %s",
len(data_uri),
)
else:
logger.warning("[employee_edit_me] No avatar in request.FILES")
logger.info(f"[employee_edit_me] Final payload keys: {list(payload.keys())}")
logger.info(
"[employee_edit_me] Final payload keys: %s",
list(payload.keys()),
)
# Отправляем в DRF как JSON (с base64 аватаром)
resp = api.patch("v1/employees/me/", json=payload)
@ -567,7 +662,27 @@ def employee_edit_me(request):
data = resp.json
except Exception:
data = {"detail": resp.text}
return JsonResponse(data or {}, status=resp.status)
accepts = (request.headers.get("Accept") or "").lower()
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
wants_json = "application/json" in accepts or is_ajax
if wants_json:
return JsonResponse(data or {}, status=resp.status)
if resp.ok:
messages.success(request, "Профиль обновлён")
else:
detail = ""
if isinstance(data, dict):
detail = data.get("detail") or data.get("error") or ""
messages.error(
request,
detail or f"Не удалось обновить профиль (HTTP {resp.status})",
)
redirect_url = request.META.get("HTTP_REFERER") or reverse("employees:profile")
return redirect(redirect_url)
# ---------- CREATE (staff) ----------

View File

@ -1,296 +1,24 @@
/**
* employeeFormHandler.js
* Обработчик формы редактирования сотрудника через API
*
* @module employeeFormHandler
* @version 1.0.0
*/
/**
* Инициализация обработчика формы редактирования сотрудника
* @param {Object} options - Параметры инициализации
* @param {string} options.formId - ID формы (по умолчанию 'apiEditForm')
* @param {string[]} options.contactFields - Список полей контактов для валидации
* @returns {Object} API формы { refresh, submit, clearErrors }
* Минимальный стаб, оставленный ради обратной совместимости после
* отказа от кастомного JS-сабмита. Теперь форма отправляется браузером
* напрямую в фронтовую Django-вьюху, а модуль лишь возвращает
* совместимое API без вмешательства в submit.
*/
export function initEmployeeForm(options = {}) {
const config = {
formId: options.formId || 'apiEditForm',
contactFields: options.contactFields || ['email', 'phone_number', 'telegram', 'whatsapp', 'wechat']
};
const formId = options.formId || 'apiEditForm';
const form = document.getElementById(formId);
const form = document.getElementById(config.formId);
if (!form) {
return null; // Тихий выход, если форма не найдена
}
const endpoint = form.dataset.endpoint || '';
if (!endpoint) {
console.warn('Employee form: endpoint not found');
console.warn('[EmployeeForm] Форма не найдена, инициализация пропущена');
return null;
}
const CONTACT_FIELDS = config.contactFields;
const btn = form.querySelector('[type="submit"]');
const btnLabel = btn?.querySelector('.btn-label');
const btnSpin = btn?.querySelector('.btn-spinner');
console.info('[EmployeeForm] JS-обработчик отключён, используется нативная отправка формы');
/**
* Установить состояние отправки формы
*/
function setSubmitting(on) {
if (!btn) return;
if (on) {
btn.setAttribute('disabled', '');
btnSpin?.classList.remove('d-none');
btnLabel?.classList.add('visually-hidden');
} else {
btn.removeAttribute('disabled');
btnSpin?.classList.add('d-none');
btnLabel?.classList.remove('visually-hidden');
}
}
/**
* Очистить все ошибки валидации
*/
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach((el) => el.classList.remove('is-invalid'));
form.querySelectorAll('.invalid-feedback').forEach((el) => (el.textContent = ''));
}
/**
* Показать ошибку для конкретного поля
*/
function fieldError(name, message) {
const el = form.querySelector(`[name="${CSS.escape(name)}"]`);
if (!el) return;
el.classList.add('is-invalid');
const container = el.closest('[class^="col-"]') || el.parentElement;
let fb = container?.querySelector('.invalid-feedback');
if (!fb) {
fb = document.createElement('div');
fb.className = 'invalid-feedback';
el.after(fb);
}
fb.textContent = message || 'Некорректное значение';
}
/**
* Валидация наличия хотя бы одного контакта
*/
function ensureOneContact() {
const hasAny = CONTACT_FIELDS.some((n) => (form.elements[n]?.value || '').trim().length > 0);
if (hasAny) return true;
CONTACT_FIELDS.forEach((n) => fieldError(n, 'Укажите хотя бы один контакт'));
form.elements[CONTACT_FIELDS[0]]?.focus();
return false;
}
/**
* Прочитать файл в Base64
*/
function readFileAsDataURL(file) {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(file);
});
}
/**
* Собрать skills_ids (массив) и сравнить с начальным значением
*/
function collectSkillsPayload() {
const sel = form.querySelector('select[name="skills_ids"]');
if (!sel) return null;
const selected = Array.from(sel.selectedOptions)
.map((o) => o.value)
.filter(Boolean);
const initCsv = sel.dataset.init || '';
const initArr = initCsv
? initCsv
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [];
// Сравнение (без учёта порядка)
const norm = (a) => a.slice().sort().join(',');
if (norm(selected) === norm(initArr)) return null;
return selected;
}
/**
* Собрать общие поля (кроме skills_ids и avatar)
*/
function buildScalarPayload() {
const payload = {};
const fields = form.querySelectorAll(
'input[name]:not([type="file"]):not([name="clear_avatar"]):not(:disabled), select[name]:not([name="skills_ids"]):not(:disabled)'
);
fields.forEach((el) => {
const name = el.name;
const raw = (el.value ?? '').toString().trim();
const init = (el.dataset.init ?? el.defaultValue ?? '').toString();
// Ничего не менялось
if (raw === init) return;
// Пустое значение: для некоторых полей нужно слать null
const shouldNull = raw === '' && ['gender', 'birth_date', 'position_id'].includes(name);
if (shouldNull) {
// Меняем непустое init -> null (очистка)
if (init !== '') payload[name] = null;
return;
}
// Типизация gender в число
if (name === 'gender' && raw !== '') {
payload[name] = Number(raw);
return;
}
// Остальные — как есть (пустые строки допустимы для текстовых полей)
payload[name] = raw;
});
return payload;
}
/**
* Обработчик отправки формы
*/
async function handleSubmit(e) {
e.preventDefault();
clearErrors();
if (!ensureOneContact()) return;
setSubmitting(true);
try {
const payload = buildScalarPayload();
// skills_ids (массив)
const skills = collectSkillsPayload();
if (skills) payload.skills_ids = skills;
// avatar (файл через FormData)
const avatarInput = document.getElementById('avatarInput');
const avatarFile = avatarInput?.files?.[0] || null;
console.log('[EmployeeForm] Avatar input:', avatarInput);
console.log('[EmployeeForm] Avatar file:', avatarFile);
console.log('[EmployeeForm] Payload:', payload);
let body;
let headers = {
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]')?.value || ''
};
if (avatarFile) {
// Если есть файл - используем FormData
console.log('[EmployeeForm] Using FormData with avatar:', avatarFile.name, avatarFile.size);
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;
console.log('[EmployeeForm] FormData entries:');
for (let [key, value] of formData.entries()) {
console.log(` ${key}:`, value);
}
// Не устанавливаем Content-Type для FormData - браузер сам добавит boundary
} else {
// Если файла нет - используем JSON
console.log('[EmployeeForm] Using JSON (no avatar)');
headers['Content-Type'] = 'application/json';
body = JSON.stringify(payload);
}
// Если нечего отправлять — просто обновим UI
if (Object.keys(payload).length === 0 && !avatarFile) {
console.log('[EmployeeForm] Nothing to save, reloading...');
setSubmitting(false);
if (window.bootstrap) {
try {
new bootstrap.Collapse(document.getElementById('editFormWrap'), { toggle: false }).hide();
} catch (_) {}
}
location.reload();
return;
}
console.log('[EmployeeForm] Sending PATCH request to:', endpoint);
console.log('[EmployeeForm] Headers:', headers);
console.log('[EmployeeForm] Body type:', body instanceof FormData ? 'FormData' : typeof body);
const resp = await fetch(endpoint, {
method: 'PATCH',
headers: headers,
body: body,
credentials: 'same-origin'
});
console.log('[EmployeeForm] Response status:', resp.status, resp.statusText);
if (!resp.ok) {
let body = null;
try {
body = await resp.json();
} catch {}
if (body && typeof body === 'object') {
let shown = false;
for (const [k, v] of Object.entries(body)) {
if (k === 'detail') continue;
const msg = Array.isArray(v) ? v.join(' ') : String(v);
fieldError(k, msg);
shown = true;
}
if (body.detail) alert(body.detail);
else if (!shown) alert('Не удалось сохранить изменения (ошибка валидации).');
} else {
const t = await resp.text();
alert('Не удалось сохранить изменения.\n' + (t || `HTTP ${resp.status}`));
}
setSubmitting(false);
return;
}
// Успех
try {
if (window.bootstrap)
new bootstrap.Collapse(document.getElementById('editFormWrap'), { toggle: false }).hide();
} catch (_) {}
location.reload();
} catch (err) {
console.error(err);
alert('Не удалось сохранить изменения. Попробуйте ещё раз.');
setSubmitting(false);
}
}
form.addEventListener('submit', handleSubmit);
// API возвращаем для внешнего использования
const noop = () => {};
return {
clearErrors,
setSubmitting,
fieldError,
setSubmitting: noop,
refresh: () => location.reload()
};
}

View File

@ -3,8 +3,13 @@
{% load static %}
<link rel="stylesheet" href="{% static 'css/components/modal-overrides.css' %}">
<form id="apiEditForm" class="row g-3" data-endpoint="{{ endpoint }}"
action="{% if is_me %}{% url 'employees:employee_set_position_me' %}{% else %}{% url 'employees:employee_set_position' emp.id %}{% endif %}">
<form
id="apiEditForm"
class="row g-3"
method="post"
enctype="multipart/form-data"
data-endpoint="{{ endpoint }}"
action="{{ endpoint }}">
{% csrf_token %}
{# === ЛИЧНЫЕ ДАННЫЕ === #}

View File

@ -14,15 +14,25 @@
<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 %}
{% with emp.avatar.url|default:emp.avatar as avatar_url %}
{% if avatar_url %}
<img
id="avatarPreview"
src="{{ 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 %}
{% endwith %}
</div>
<div class="flex-grow-1">
<input type="file" class="form-control mb-2" id="avatarInput" name="avatar" accept="image/*">