diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index e1f21356..59c8b15c 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -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) ---------- diff --git a/backend/static/js/components/employeeFormHandler.js b/backend/static/js/components/employeeFormHandler.js index 7ae070c4..1781bbc3 100644 --- a/backend/static/js/components/employeeFormHandler.js +++ b/backend/static/js/components/employeeFormHandler.js @@ -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() }; } diff --git a/backend/templates/employees/_employee_edit.html b/backend/templates/employees/_employee_edit.html index b79edcf1..f60e2f69 100644 --- a/backend/templates/employees/_employee_edit.html +++ b/backend/templates/employees/_employee_edit.html @@ -3,8 +3,13 @@ {% load static %} -
+ {% csrf_token %} {# === ЛИЧНЫЕ ДАННЫЕ === #} diff --git a/backend/templates/employees/components/employee_form_personal.html b/backend/templates/employees/components/employee_form_personal.html index f3baa31f..8dc180f9 100644 --- a/backend/templates/employees/components/employee_form_personal.html +++ b/backend/templates/employees/components/employee_form_personal.html @@ -14,15 +14,25 @@
- {% if emp.avatar %} - Фото - {% else %} -
- -
- {% endif %} + {% with emp.avatar.url|default:emp.avatar as avatar_url %} + {% if avatar_url %} + Фото + {% else %} +
+ +
+ {% endif %} + {% endwith %}