diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 0d423166..1be5c8c1 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -1,4 +1,5 @@ from collections import defaultdict +import re from typing import Any, Dict, Optional from django.contrib.auth import get_user_model @@ -53,18 +54,73 @@ class RegisterSerializer(serializers.Serializer): whatsapp = serializers.CharField(required=False, allow_blank=True, default="") wechat = serializers.CharField(required=False, allow_blank=True, default="") - avatar = Base64ImageField(required=False, allow_null=True) + avatar = Base64ImageField(required=True) patronymic = serializers.CharField(required=False, allow_blank=True, default="") gender = serializers.ChoiceField( - required=False, - allow_null=True, - choices=((0, "Не указан"), (1, "Мужской"), (2, "Женский")), + required=True, + choices=((1, "Мужской"), (2, "Женский")), + error_messages={ + 'required': 'Поле "Пол" обязательно для заполнения.', + 'invalid_choice': 'Укажите пол: 1 - Мужской, 2 - Женский.' + } ) position = serializers.IntegerField(required=False, allow_null=True) skills = serializers.ListField(child=serializers.IntegerField(), required=False) + def validate_first_name(self, value: str) -> str: + """Валидация имени: только буквы, пробелы и дефисы.""" + value = value.strip() + if not value: + raise serializers.ValidationError( + "Имя обязательно для заполнения." + ) + if re.search(r'\d', value): + raise serializers.ValidationError( + "Имя не должно содержать цифры." + ) + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise serializers.ValidationError( + "Имя может содержать только буквы, пробелы, " + "дефисы и апострофы." + ) + return value + + def validate_last_name(self, value: str) -> str: + """Валидация фамилии: только буквы, пробелы и дефисы.""" + value = value.strip() + if not value: + raise serializers.ValidationError( + "Фамилия обязательна для заполнения." + ) + if re.search(r'\d', value): + raise serializers.ValidationError( + "Фамилия не должна содержать цифры." + ) + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise serializers.ValidationError( + "Фамилия может содержать только буквы, пробелы, " + "дефисы и апострофы." + ) + return value + + def validate_patronymic(self, value: str) -> str: + """Валидация отчества: только буквы, пробелы и дефисы.""" + value = value.strip() + if not value: + return value # Отчество опциональное + if re.search(r'\d', value): + raise serializers.ValidationError( + "Отчество не должно содержать цифры." + ) + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise serializers.ValidationError( + "Отчество может содержать только буквы, пробелы, " + "дефисы и апострофы." + ) + return value + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: """Глобальная валидация: контакты + телефон (alias) + нормализация E.164. diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 64b70f33..df0e7927 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -439,11 +439,10 @@ class RegisterAPIView(APIView): emp.whatsapp = v.get("whatsapp", "") emp.wechat = v.get("wechat", "") emp.birth_date = v["birth_date"] + emp.gender = v["gender"] # обязательное поле if v.get("patronymic"): emp.patronymic = v["patronymic"] - if v.get("gender") is not None: - emp.gender = v["gender"] pos_id = v.get("position") if pos_id and Position.objects.filter(pk=pos_id).exists(): diff --git a/backend/employees/forms_auth.py b/backend/employees/forms_auth.py index 02cb5d59..1f1a160b 100644 --- a/backend/employees/forms_auth.py +++ b/backend/employees/forms_auth.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 +import re from typing import Any, Dict, List, Optional from django import forms @@ -50,10 +51,8 @@ class RegistrationForm(forms.Form): widget=forms.TextInput(attrs={ "type": "tel", "inputmode": "numeric", - "pattern": r"^\+?\d{10,15}$", "autocomplete": "tel", - "data-phone-only": "1", - "title": "Введите номер в формате +79991234567 (10–15 цифр)", + "title": "Введите номер в формате +79991234567", }) ) email = forms.EmailField(label="Email") @@ -72,11 +71,66 @@ class RegistrationForm(forms.Form): # Опциональные поля модели patronymic = forms.CharField(required=False, label="Отчество") - gender = forms.IntegerField(required=False, label="Пол (0/1/2)") + gender = forms.ChoiceField( + required=True, + label="Пол", + choices=[(1, "Мужской"), (2, "Женский")], + widget=forms.RadioSelect, + error_messages={ + 'required': 'Необходимо указать пол.', + 'invalid_choice': 'Выберите Мужской или Женский.', + } + ) position = forms.IntegerField(required=False, label="ID должности") # skills — список ID через запятую skills = forms.CharField(required=False, label="Навыки (ID через запятую)") - avatar = forms.ImageField(required=False, label="Аватар") + avatar = forms.ImageField(required=True, label="Аватар") + + def clean_first_name(self) -> str: + """Валидация имени: только буквы, пробелы и дефисы.""" + value = self.cleaned_data.get("first_name", "").strip() + if not value: + raise forms.ValidationError("Имя обязательно для заполнения.") + if re.search(r'\d', value): + raise forms.ValidationError( + "Имя не должно содержать цифры." + ) + # Разрешаем буквы (любых алфавитов), пробелы, дефисы, апострофы + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise forms.ValidationError( + "Имя может содержать только буквы, пробелы, дефисы и апострофы." + ) + return value + + def clean_last_name(self) -> str: + """Валидация фамилии: только буквы, пробелы и дефисы.""" + value = self.cleaned_data.get("last_name", "").strip() + if not value: + raise forms.ValidationError("Фамилия обязательна для заполнения.") + if re.search(r'\d', value): + raise forms.ValidationError( + "Фамилия не должна содержать цифры." + ) + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise forms.ValidationError( + "Фамилия может содержать только буквы, пробелы, дефисы и апострофы." + ) + return value + + def clean_patronymic(self) -> str: + """Валидация отчества: только буквы, пробелы и дефисы.""" + value = self.cleaned_data.get("patronymic", "").strip() + if not value: + return value # Отчество опциональное + if re.search(r'\d', value): + raise forms.ValidationError( + "Отчество не должно содержать цифры." + ) + if not re.match(r'^[\w\s\-\']+$', value, re.UNICODE): + raise forms.ValidationError( + "Отчество может содержать только буквы, пробелы, дефисы и апострофы." + ) + return value def clean(self) -> Dict[str, Any]: """Глобальная валидация формы. @@ -232,6 +286,7 @@ class RegistrationForm(forms.Form): "email": cd["email"], "birth_date": cd["birth_date"].isoformat(), "password": cd["password1"], + "gender": int(cd["gender"]), # обязательное поле "telegram": cd.get("telegram") or "", "whatsapp": cd.get("whatsapp") or "", "wechat": cd.get("wechat") or "", @@ -240,8 +295,6 @@ class RegistrationForm(forms.Form): # Доп. поля if cd.get("patronymic"): payload["patronymic"] = cd["patronymic"] - if cd.get("gender") is not None: - payload["gender"] = cd["gender"] if cd.get("position") is not None: payload["position"] = cd["position"] diff --git a/backend/static/js/components/liveValidationHandler.js b/backend/static/js/components/liveValidationHandler.js new file mode 100644 index 00000000..3f501ca1 --- /dev/null +++ b/backend/static/js/components/liveValidationHandler.js @@ -0,0 +1,475 @@ +/** + * @module liveValidationHandler + * @description Валидация формы в реальном времени с подсветкой ошибок. + * Проверяет поля по мере ввода и показывает feedback сразу. + * + * Использование: + * import { initLiveValidation } from './liveValidationHandler.js'; + * + * initLiveValidation({ + * formSelector: '#registrationForm', + * rules: { + * email: [ + * { type: 'required', message: 'Email обязателен' }, + * { type: 'email', message: 'Введите корректный email' } + * ], + * phone_number: [ + * { type: 'required', message: 'Телефон обязателен' }, + * { type: 'pattern', pattern: /^\+?\d{10,15}$/, message: 'Неверный формат телефона' } + * ], + * password1: [ + * { type: 'required', message: 'Пароль обязателен' }, + * { type: 'minLength', value: 6, message: 'Минимум 6 символов' } + * ], + * password2: [ + * { type: 'required', message: 'Подтвердите пароль' }, + * { type: 'match', field: 'password1', message: 'Пароли не совпадают' } + * ], + * gender: [ + * { type: 'required', message: 'Укажите пол' } + * ] + * } + * }); + */ + +/** + * Санитизация телефонного номера: оставляет только цифры и +. + * @param {string} value - Исходное значение + * @returns {string} Очищенное значение + */ +function sanitizePhone(value) { + value = String(value || ''); + let output = ''; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + // Цифры пропускаем всегда + if (char >= '0' && char <= '9') { + output += char; + continue; + } + + // Плюс только в начале + if (char === '+' && output.length === 0) { + output = '+'; + } + } + + return output; +} + +/** + * Валидаторы для различных типов проверок + */ +const validators = { + required: (value, input) => { + // Специальная обработка для file input + if (input && input.type === 'file') { + return input.files && input.files.length > 0; + } + + if (typeof value === 'string') { + return value.trim().length > 0; + } + return value !== null && value !== undefined && value !== ''; + }, + + email: (value) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(value); + }, + + pattern: (value, pattern) => { + return pattern.test(value); + }, + + minLength: (value, minLen) => { + return value.length >= minLen; + }, + + maxLength: (value, maxLen) => { + return value.length <= maxLen; + }, + + match: (value, formData, fieldName) => { + return value === formData[fieldName]; + }, + + // Специальная валидация для контактов (хотя бы один) + anyContact: (value, formData, fieldName, fields) => { + return fields.some(field => { + const val = formData[field]; + return val && val.trim().length > 0; + }); + } +}; + +/** + * Проверяет поле на соответствие правилам + * @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля + * @param {Array} rules - Массив правил валидации + * @param {Object} formData - Данные всей формы + * @returns {Object} { valid: boolean, message: string } + */ +function validateField(input, rules, formData) { + if (!rules || rules.length === 0) { + return { valid: true, message: '' }; + } + + const value = input.value; + const fieldName = input.name; + + for (const rule of rules) { + let isValid = true; + + switch (rule.type) { + case 'required': + isValid = validators.required(value, input); + break; + + case 'email': + // Email проверяем только если поле не пустое + if (value.trim().length > 0) { + isValid = validators.email(value); + } + break; + + case 'pattern': + // Pattern проверяем только если поле не пустое + if (value.trim().length > 0) { + isValid = validators.pattern(value, rule.pattern); + } else if (rule.required === false) { + // Если поле опциональное и пустое - валидно + isValid = true; + } else { + // Если поле обязательное, но пустое - будет проверено правилом required + isValid = true; + } + break; + + case 'minLength': + // MinLength проверяем только если поле не пустое + if (value.trim().length > 0) { + isValid = validators.minLength(value, rule.value); + } + break; + + case 'maxLength': + isValid = validators.maxLength(value, rule.value); + break; + + case 'match': + isValid = validators.match(value, formData, rule.field); + break; + + case 'anyContact': + isValid = validators.anyContact(value, formData, fieldName, rule.fields); + break; + + default: + // Кастомная функция валидации + if (typeof rule.validate === 'function') { + isValid = rule.validate(value, formData, input); + } + } + + if (!isValid) { + return { valid: false, message: rule.message }; + } + } + + return { valid: true, message: '' }; +} + +/** + * Отображает feedback для поля + * @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля + * @param {boolean} valid - Валидно ли поле + * @param {string} message - Сообщение об ошибке + */ +function showFieldFeedback(input, valid, message) { + // Очищаем HTML5 custom validity чтобы не конфликтовать + input.setCustomValidity(''); + + // Удаляем старые классы + input.classList.remove('is-valid', 'is-invalid'); + + // Ищем существующий feedback или создаём новый + let feedbackEl = input.parentElement.querySelector('.invalid-feedback'); + + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'invalid-feedback'; + input.parentElement.appendChild(feedbackEl); + } + + if (valid) { + input.classList.add('is-valid'); + feedbackEl.textContent = ''; + feedbackEl.style.display = 'none'; + } else { + input.classList.add('is-invalid'); + feedbackEl.textContent = message; + feedbackEl.style.display = 'block'; + } +} + +/** + * Очищает feedback для поля + * @param {HTMLInputElement|HTMLSelectElement} input - Элемент поля + */ +function clearFieldFeedback(input) { + input.classList.remove('is-valid', 'is-invalid'); + const feedbackEl = input.parentElement.querySelector('.invalid-feedback'); + if (feedbackEl) { + feedbackEl.textContent = ''; + feedbackEl.style.display = 'none'; + } +} + +/** + * Собирает данные формы в объект + * @param {HTMLFormElement} form - Элемент формы + * @returns {Object} Данные формы + */ +function getFormData(form) { + const formData = {}; + const elements = form.elements; + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + if (element.name) { + if (element.type === 'radio') { + if (element.checked) { + formData[element.name] = element.value; + } + } else if (element.type === 'checkbox') { + formData[element.name] = element.checked; + } else { + formData[element.name] = element.value; + } + } + } + + return formData; +} + +/** + * Инициализирует live validation для формы + * @param {Object} options - Настройки + * @param {string} options.formSelector - CSS селектор формы + * @param {Object} options.rules - Правила валидации для полей + * @param {number} [options.debounce=300] - Задержка перед валидацией (мс) + * @param {boolean} [options.validateOnBlur=true] - Валидировать при потере фокуса + * @param {boolean} [options.validateOnInput=true] - Валидировать при вводе + * @param {boolean} [options.validateOnSubmit=true] - Валидировать при отправке + * @returns {Object} API для управления валидацией + */ +export function initLiveValidation(options = {}) { + const { + formSelector, + rules = {}, + debounce = 300, + validateOnBlur = true, + validateOnInput = true, + validateOnSubmit = true + } = options; + + const form = document.querySelector(formSelector); + if (!form) { + console.error(`Form not found: ${formSelector}`); + return null; + } + + const validationTimers = {}; + const touchedFields = new Set(); + + /** + * Валидирует конкретное поле + * @param {HTMLInputElement|HTMLSelectElement} input + */ + const validateInput = (input) => { + const fieldName = input.name; + const fieldRules = rules[fieldName]; + + if (!fieldRules) return; + + const formData = getFormData(form); + const result = validateField(input, fieldRules, formData); + + showFieldFeedback(input, result.valid, result.message); + }; + + /** + * Валидирует поле с задержкой (debounce) + * @param {HTMLInputElement|HTMLSelectElement} input + */ + const validateInputDebounced = (input) => { + const fieldName = input.name; + + // Очищаем предыдущий таймер + if (validationTimers[fieldName]) { + clearTimeout(validationTimers[fieldName]); + } + + // Устанавливаем новый таймер + validationTimers[fieldName] = setTimeout(() => { + validateInput(input); + }, debounce); + }; + + /** + * Обработчик ввода в поле + */ + const handleInput = (e) => { + const input = e.target; + const fieldName = input.name; + + // Санитизация телефонных номеров + if (input.type === 'tel') { + const previousValue = input.value; + const sanitized = sanitizePhone(previousValue); + + if (previousValue !== sanitized) { + const cursorPosition = input.selectionStart || sanitized.length; + const diff = previousValue.length - sanitized.length; + + input.value = sanitized; + + try { + const newPosition = Math.max(0, cursorPosition - diff); + input.setSelectionRange(newPosition, newPosition); + } catch (err) { + // Игнорируем ошибки setSelectionRange + } + } + } + + if (!validateOnInput) return; + + // Валидируем только если поле уже было "тронуто" + if (touchedFields.has(fieldName)) { + validateInputDebounced(input); + } + }; + + /** + * Обработчик потери фокуса + */ + const handleBlur = (e) => { + const input = e.target; + const fieldName = input.name; + + // Помечаем поле как "тронутое" + touchedFields.add(fieldName); + + if (validateOnBlur && rules[fieldName]) { + validateInput(input); + } + }; + + /** + * Обработчик отправки формы + */ + const handleSubmit = (e) => { + if (!validateOnSubmit) return; + + let isFormValid = true; + const formData = getFormData(form); + + // Валидируем все поля с правилами + for (const fieldName in rules) { + const input = form.elements[fieldName]; + if (!input) continue; + + const result = validateField(input, rules[fieldName], formData); + showFieldFeedback(input, result.valid, result.message); + + if (!result.valid) { + isFormValid = false; + } + } + + // Прерываем отправку если есть ошибки + if (!isFormValid) { + e.preventDefault(); + + // Фокусируем первое невалидное поле + const firstInvalid = form.querySelector('.is-invalid'); + if (firstInvalid) { + firstInvalid.focus(); + } + } + }; + + // Навешиваем обработчики + form.addEventListener('input', handleInput); + form.addEventListener('blur', handleBlur, true); // true для capture phase + form.addEventListener('submit', handleSubmit); + + /** + * API для управления валидацией + */ + return { + /** + * Валидирует конкретное поле вручную + */ + validateField: (fieldName) => { + const input = form.elements[fieldName]; + if (input) { + touchedFields.add(fieldName); + validateInput(input); + } + }, + + /** + * Валидирует всю форму + */ + validateForm: () => { + let isValid = true; + const formData = getFormData(form); + + for (const fieldName in rules) { + const input = form.elements[fieldName]; + if (!input) continue; + + const result = validateField(input, rules[fieldName], formData); + showFieldFeedback(input, result.valid, result.message); + + if (!result.valid) { + isValid = false; + } + } + + return isValid; + }, + + /** + * Очищает валидацию для всех полей + */ + clearValidation: () => { + for (const fieldName in rules) { + const input = form.elements[fieldName]; + if (input) { + clearFieldFeedback(input); + } + } + touchedFields.clear(); + }, + + /** + * Удаляет обработчики + */ + destroy: () => { + form.removeEventListener('input', handleInput); + form.removeEventListener('blur', handleBlur, true); + form.removeEventListener('submit', handleSubmit); + } + }; +} + +// Экспорт для совместимости с неModular кодом +if (typeof window !== 'undefined') { + window.initLiveValidation = initLiveValidation; +} diff --git a/backend/templates/auth/login.html b/backend/templates/auth/login.html index 608648a0..f7690044 100644 --- a/backend/templates/auth/login.html +++ b/backend/templates/auth/login.html @@ -29,11 +29,13 @@ {% endif %}
- {% if form.username.errors %} @@ -43,21 +45,27 @@ {% endif %}
-
- - - - {% if form.password.errors %} -
- {{ form.password.errors|striptags }} +
+
+ + + {% if form.password.errors %} +
+ {{ form.password.errors|striptags }} +
+ {% endif %}
- {% endif %} + + +
diff --git a/backend/templates/auth/register.html b/backend/templates/auth/register.html index 4d7d5f82..958c110e 100644 --- a/backend/templates/auth/register.html +++ b/backend/templates/auth/register.html @@ -26,7 +26,6 @@
{% bootstrap_field form.email show_label=False placeholder="E-mail" %} - Пример: ivanov@company.com
{% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %} @@ -77,21 +76,8 @@ {% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %}
- -
-
- - -
-
- - -
-
- - -
-
+ + {% bootstrap_field form.gender show_label=False %}
@@ -99,14 +85,18 @@
{% bootstrap_field form.password1 show_label=False placeholder="Пароль" %} - - + +
{% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %} - - + +
@@ -146,7 +136,7 @@ {% endblock %}