From 5af1f9ec9111aa2a0c6ffd321b6a2d5224790e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 20 Aug 2025 17:49:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F/=D0=B2=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20API,=20whitelist=20=D0=B2=20middleware,=20iOS-?= =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D1=8B?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Что сделано** * **API** * Добавлен `POST /api/v1/auth/register/` — создание пользователя, генерация кода и письмо (повторная отправка, если email есть, но не подтверждён). * `POST /api/v1/auth/verify-email/` возвращает `user_id` при успехе. * `POST /api/v1/auth/resend-email/` — без изменений по контракту. * **Фронт (views\_front)** * `RegisterView.form_valid` теперь вызывает API `auth/register` (без прямой работы с ORM). * `EmailVerifyView.post` переводён на API `auth/verify-email` + автологин при успехе. * Повторная отправка кода (`resend_email`) уже через API, добавлена защита от частых запросов по сессии. * Исправлена сериализация телефона (E.164/строка) при отправке в API. * **Клиент API** * Новый модуль `employees/api_client.py`: безопасная сериализация payload, удобные ошибки (`api_bad_json status=… body=…`) и абсолютные URL через `request.build_absolute_uri`. * **Middleware** * `AuthRequiredMiddleware` теперь **пропускает**: `/api/v1/**`, статику/медиа, страницы логина/регистрации/сброса пароля, админ-логин и `OPTIONS`. Остальное для анонима — редирект на `login`. * **Аутентификация/выход** * Страница подтверждения выхода (в стиле iOS bottom sheet) + `ConfirmLogoutView`. Пункт «Выйти» ведёт на GET `/auth/logout/`, далее POST подтверждения. * Шаблон выхода принимает `next` из контекста (без обращений к `request.GET.next` в шаблоне). * **UX/мелочи** * Устранены 500 при отсутствии `password_reset` — добавлены/учтены URL’ы потока сброса (или безопасные ссылки). * Уточнены сообщения об ошибках при регистрации/верификации. **Как проверить** 1. Аноним может вызвать: * `POST /api/v1/auth/register/` → `201 {"ok":true}` / `200 {"ok":true,"resent":true}` / `400 {"error":"email_taken"}`. * `POST /api/v1/auth/verify-email/` с корректным кодом → `200 {"ok":true,"user_id":...}`. * `POST /api/v1/auth/resend-email/` → `200 {"ok":true}`. 2. UI: * Регистрация в форме → обращение к API, письмо с кодом, переход на ввод кода. * Ввод кода → API, автологин, редирект в ленту. * «Выйти» в меню → страница подтверждения → POST → сообщение «Вы вышли…» и редирект на логин/`next`. 3. Проверить, что `/api/v1/**` не редиректится на логин (middleware whitelist работает). 4. Телефон в форме не ломает JSON (нет `TypeError: PhoneNumber is not JSON serializable`). **Миграции**: нет. --- backend/api/urls.py | 6 +- backend/api/v1/employees/serializers.py | 268 +------------ backend/api/v1/employees/views.py | 277 ++++++++------ backend/api/v1/urls.py | 24 +- backend/common/__init__.py | 0 backend/common/emails.py | 44 +++ backend/employees/api_client.py | 61 +++ backend/employees/forms.py | 66 +++- backend/employees/management/__init__.py | 0 .../employees/management/commands/__init__.py | 0 .../employees/management/commands/mailtest.py | 22 ++ ...1_department_head_appointed_at_and_more.py | 42 +++ backend/employees/models.py | 147 ++++++-- backend/employees/urls_front.py | 6 +- backend/employees/views_front.py | 355 ++++++++++++------ backend/eusrr_backend/auth_backends.py | 98 +++++ backend/eusrr_backend/forms_auth.py | 20 + backend/eusrr_backend/middleware.py | 11 +- backend/eusrr_backend/settings.py | 46 ++- backend/eusrr_backend/urls.py | 30 +- backend/eusrr_backend/views_auth.py | 77 ++++ backend/feed/views.py | 2 +- .../emails/registration_verify_code.html | 14 + backend/templates/includes/navbar.html | 11 +- .../templates/registration/email_verify.html | 103 +++++ backend/templates/registration/login.html | 4 +- backend/templates/registration/logout.html | 194 ++++++++-- .../registration/logout_confirm.html | 33 ++ .../registration/password_reset_complete.htm | 2 + .../registration/password_reset_email.txt | 6 + .../registration/password_reset_form.html | 7 + .../registration/password_reset_subject.txt | 1 + backend/templates/registration/register.html | 16 +- 33 files changed, 1380 insertions(+), 613 deletions(-) create mode 100644 backend/common/__init__.py create mode 100644 backend/common/emails.py create mode 100644 backend/employees/api_client.py create mode 100644 backend/employees/management/__init__.py create mode 100644 backend/employees/management/commands/__init__.py create mode 100644 backend/employees/management/commands/mailtest.py create mode 100644 backend/employees/migrations/0011_department_head_appointed_at_and_more.py create mode 100644 backend/eusrr_backend/auth_backends.py create mode 100644 backend/eusrr_backend/forms_auth.py create mode 100644 backend/eusrr_backend/views_auth.py create mode 100644 backend/templates/emails/registration_verify_code.html create mode 100644 backend/templates/registration/email_verify.html create mode 100644 backend/templates/registration/logout_confirm.html create mode 100644 backend/templates/registration/password_reset_complete.htm create mode 100644 backend/templates/registration/password_reset_email.txt create mode 100644 backend/templates/registration/password_reset_form.html create mode 100644 backend/templates/registration/password_reset_subject.txt diff --git a/backend/api/urls.py b/backend/api/urls.py index b6e1a1c4..04d4ade3 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,5 +1,9 @@ +# backend\api\urls.py + from django.urls import path, include urlpatterns = [ - path('v1/', include('api.v1.urls')), + path( + 'v1/', include(('api.v1.urls', "api_v1"), namespace="api_v1") + ), ] \ No newline at end of file diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 67f8926f..bc16a761 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -1,261 +1,15 @@ from rest_framework import serializers -from employees.models import Employee, Department, EmployeeAction, EmployeePosition, Absence, Skill, Education - - -class SkillSerializer(serializers.ModelSerializer): - class Meta: - model = Skill - fields = ['id', 'name'] - - -class DepartmentSerializer(serializers.ModelSerializer): - head = serializers.StringRelatedField() - - class Meta: - model = Department - fields = ['id', 'name', 'description', 'head', 'created_at'] - - -class EmployeeActionSerializer(serializers.ModelSerializer): - employee = serializers.StringRelatedField(read_only=True) - - class Meta: - model = EmployeeAction - fields = ['id', 'employee', 'action', 'date', 'comment', 'extra'] - - -class EmployeePositionSerializer(serializers.ModelSerializer): - department = DepartmentSerializer(read_only=True) - employee = serializers.StringRelatedField(read_only=True) - - class Meta: - model = EmployeePosition - fields = ['id', 'employee', 'department', - 'title', 'date_from', 'date_to', 'is_active'] - - -class AbsenceSerializer(serializers.ModelSerializer): - employee = serializers.StringRelatedField(read_only=True) - - class Meta: - model = Absence - fields = ['id', 'employee', 'type', - 'date_from', 'date_to', 'comment', 'status'] - - -class EducationSerializer(serializers.ModelSerializer): - employee = serializers.StringRelatedField(read_only=True) - - class Meta: - model = Education - fields = ['id', 'employee', 'institution', 'degree', 'graduation_year'] - - -class EmployeeSerializer(serializers.ModelSerializer): - skills = SkillSerializer(many=True, read_only=True) - positions = EmployeePositionSerializer(many=True, read_only=True) - actions = EmployeeActionSerializer(many=True, read_only=True) - absences = AbsenceSerializer(many=True, read_only=True) - educations = EducationSerializer(many=True, read_only=True) - department = serializers.SerializerMethodField() - avatar = serializers.SerializerMethodField() - - class Meta: - model = Employee - fields = [ - 'id', 'last_name', 'first_name', 'patronymic', 'gender', 'avatar', - 'phone_number', 'email', 'telegram', 'whatsapp', 'wechat', - 'birth_date', 'skills', 'positions', 'actions', 'absences', 'educations', - 'employment_status', 'is_actually_active', 'created_at', 'updated_at', 'department' - ] - - def get_department(self, obj): - active_position = obj.positions.filter(date_to__isnull=True).first() - if active_position: - return DepartmentSerializer(active_position.department).data - return None - - def get_avatar(self, obj): - request = self.context.get('request') - if obj.avatar and request: - return request.build_absolute_uri(obj.avatar.url) - return None - - - - - - - - - - - - - -# Календарь------------------------------------------------------------------------ - -# backend/api/serializers.py -import datetime as dt - -from django.utils import timezone -from django.utils.dateparse import parse_date, parse_datetime -from rest_framework import serializers -from calendar_app.models import CompanyEvent, DepartmentEvent, Recurrence +class EmailSerializer(serializers.Serializer): + email = serializers.EmailField() +class EmailVerifySerializer(serializers.Serializer): + email = serializers.EmailField() + code = serializers.CharField(max_length=32) -def _aware(dt_or_date: dt.datetime | dt.date, to_utc: bool = False) -> dt.datetime: - """ - Делает datetime aware в текущем TZ (или переводит в UTC). - Если пришла date — берём локальную полночь. - """ - if isinstance(dt_or_date, dt.date) and not isinstance(dt_or_date, dt.datetime): - dtime = dt.datetime.combine(dt_or_date, dt.time.min) - else: - dtime = dt_or_date # type: ignore[assignment] - - if timezone.is_naive(dtime): - dtime = timezone.make_aware(dtime, timezone.get_current_timezone()) - - if to_utc: - dtime = timezone.localtime(dtime, timezone.utc) - return dtime - - -def _as_iso(value, *, all_day: bool = True, to_utc: bool = False) -> str | None: - """ - Приводит value (date | datetime | str | None) к ISO-строке. - - Для all-day отдаём YYYY-MM-DD (без времени — так рекомендует FullCalendar). - - Для timed делаем aware datetime (локальный TZ по умолчанию или UTC). - """ - if value is None: - return None - - # datetime - if isinstance(value, dt.datetime): - dtime = _aware(value, to_utc=to_utc) - return dtime.isoformat() - - # date - if isinstance(value, dt.date): - if all_day: - return value.isoformat() - return _aware(value, to_utc=to_utc).isoformat() - - # str - if isinstance(value, str): - dtime = parse_datetime(value) - if dtime: - return _aware(dtime, to_utc=to_utc).isoformat() - d = parse_date(value) - if d: - return d.isoformat() if all_day else _aware(d, to_utc=to_utc).isoformat() - return value # оставим как есть, чтобы не ронять API - - return None - - -class CompanyEventSerializer(serializers.ModelSerializer): - start = serializers.SerializerMethodField() - end = serializers.SerializerMethodField() - allDay = serializers.SerializerMethodField() - url = serializers.SerializerMethodField() - recurrence_display = serializers.SerializerMethodField() - - class Meta: - model = CompanyEvent - fields = ( - "id", - "title", - "description", - "start", - "end", - "allDay", - "color", - "location", - "recurrence", - "recurrence_display", - "url", - ) - read_only_fields = fields - - def get_start(self, obj: CompanyEvent): - # Однодневное корпоративное событие - occ = getattr(obj, "occurrence_date", None) - return _as_iso(occ or obj.date, all_day=True) - - def get_end(self, obj: CompanyEvent): - # Для all-day можно вернуть None (FullCalendar корректно отобразит один день) - return None - - def get_allDay(self, obj: CompanyEvent): - return True - - def get_url(self, obj: CompanyEvent): - # Если детальной страницы нет — можно вернуть календарь компании - try: - return obj.get_absolute_url() - except Exception: - return None - - def get_recurrence_display(self, obj: CompanyEvent): - return obj.get_recurrence_display() - - -class DepartmentEventSerializer(serializers.ModelSerializer): - start = serializers.SerializerMethodField() - end = serializers.SerializerMethodField() - allDay = serializers.SerializerMethodField() - url = serializers.SerializerMethodField() - recurrence_display = serializers.SerializerMethodField() - department_name = serializers.SerializerMethodField() - - class Meta: - model = DepartmentEvent - fields = ( - "id", - "title", - "description", - "start", - "end", - "allDay", - "color", - "location", - "recurrence", - "recurrence_display", - "department", # id отдела - "department_name", - "url", - ) - read_only_fields = fields - - def get_start(self, obj): - start = getattr(obj, "occ_start_date", obj.start_date) - return _as_iso(start, all_day=obj.all_day) - - def get_end(self, obj): - end = getattr(obj, "occ_end_date", obj.end_date) - if not end: - return None - if obj.all_day: - # end-exclusive для all-day - return _as_iso(end + dt.timedelta(days=1), all_day=True) - return _as_iso(end, all_day=False) - - def get_allDay(self, obj: DepartmentEvent): - return bool(obj.all_day) - - def get_url(self, obj: DepartmentEvent): - try: - return obj.get_absolute_url() - except Exception: - return None - - def get_recurrence_display(self, obj: DepartmentEvent): - return obj.get_recurrence_display() - - def get_department_name(self, obj: DepartmentEvent): - # избежим N+1: в view делайте select_related("department") - dept = getattr(obj, "department", None) - return getattr(dept, "name", None) +class RegisterSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(write_only=True, min_length=8) + first_name = serializers.CharField(required=False, allow_blank=True, max_length=150) + last_name = serializers.CharField(required=False, allow_blank=True, max_length=150) + phone = serializers.CharField(required=False, allow_blank=True, max_length=32) \ No newline at end of file diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index 3ef0ce57..a3023cc3 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -1,128 +1,173 @@ -from employees.models import (Absence, Department, Education, Employee, - EmployeeAction, EmployeePosition, Skill) -from rest_framework import permissions, status, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from common.emails import send_templated_mail +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from django.utils.crypto import get_random_string +from employees.models import Employee +from rest_framework import status from rest_framework.response import Response +from rest_framework.views import APIView -from ..permissions import IsDepartmentHeadOrAdmin, IsHR, IsSelfOrAdmin -from .serializers import (AbsenceSerializer, DepartmentSerializer, - EducationSerializer, EmployeeActionSerializer, - EmployeePositionSerializer, EmployeeSerializer, - SkillSerializer) +from .serializers import EmailSerializer, EmailVerifySerializer, RegisterSerializer + +# опциональная нормализация телефона +try: + import phonenumbers + from phonenumbers import PhoneNumberFormat +except Exception: + phonenumbers = None + PhoneNumberFormat = None -class EmployeeViewSet(viewsets.ModelViewSet): - queryset = Employee.objects.all().prefetch_related("skills", "positions", "actions") - serializer_class = EmployeeSerializer +class ResendEmailAPIView(APIView): + """ + POST /api/v1/auth/resend-email/ + body: {"email": "user@ex.com"} + """ - def get_permissions(self): - if self.action in [ - "update", - "partial_update", - "destroy", - "set_avatar", - "add_skill", - ]: - return [IsSelfOrAdmin()] - return [permissions.IsAuthenticated()] + throttle_scope = "anon" - @action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin]) - def set_avatar(self, request, pk=None): - employee = self.get_object() - avatar = request.FILES.get("avatar") - if avatar: - employee.avatar = avatar - employee.save() - return Response({"status": "avatar set"}) - return Response({"error": "No file"}, status=status.HTTP_400_BAD_REQUEST) + def post(self, request): + ser = EmailSerializer(data=request.data) + ser.is_valid(raise_exception=True) + email = ser.validated_data["email"] - @action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin]) - def add_skill(self, request, pk=None): - employee = self.get_object() - skill_name = request.data.get("skill") - if skill_name: - skill, _ = Skill.objects.get_or_create(name=skill_name) - employee.skills.add(skill) - return Response({"status": "skill added"}) - return Response({"error": "No skill"}, status=status.HTTP_400_BAD_REQUEST) + user = Employee.objects.filter(email__iexact=email).first() + if not user: + return Response({"ok": False, "error": "user_not_found"}, status=404) - @action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin]) - def request_absence(self, request, pk=None): - return Response( - {"status": "Not implemented"}, status=status.HTTP_501_NOT_IMPLEMENTED + if user.email_verified: + return Response({"ok": False, "error": "already_verified"}, status=400) + + # простая частотная защита по времени отправки в сессии оставим во вьюхе фронта, + # на API уровне можно дополнить throttle/ratelimit на IP + user.email_activation_code = get_random_string(6, "0123456789") + user.save(update_fields=["email_activation_code"]) + + send_templated_mail( + subject="Подтверждение регистрации", + to=[user.email], + template_base="emails/registration_verify_code", + context={"code": user.email_activation_code, "user": user}, ) + return Response({"ok": True}, status=200) - @action(detail=True, methods=["post"], permission_classes=[IsSelfOrAdmin]) - def request_transfer(self, request, pk=None): - return Response( - {"status": "Not implemented"}, status=status.HTTP_501_NOT_IMPLEMENTED + +class VerifyEmailAPIView(APIView): + throttle_scope = "anon" + + def post(self, request): + ser = EmailVerifySerializer(data=request.data) + ser.is_valid(raise_exception=True) + email = ser.validated_data["email"] + code = ser.validated_data["code"].strip() + + user = Employee.objects.filter(email__iexact=email).first() + if not user: + return Response({"ok": False, "error": "user_not_found"}, status=404) + if not code: + return Response({"ok": False, "error": "empty_code"}, status=400) + + if user.verify_email(code): # внутри должна ставиться is_active=True + return Response({"ok": True, "user_id": user.id}, status=200) + + return Response({"ok": False, "error": "invalid_code"}, status=400) + + +def _normalize_phone(raw: str) -> str | None: + if not raw: + return None + if phonenumbers is None: + return raw.strip() + region = getattr(settings, "PHONE_DEFAULT_REGION", "RU") + try: + pn = phonenumbers.parse(str(raw), region) + if not phonenumbers.is_valid_number(pn): + return None + return phonenumbers.format_number(pn, PhoneNumberFormat.E164) + except Exception: + return None + + +def _detect_phone_field() -> str | None: + for n in ("phone", "phone_number", "mobile", "msisdn", "tel"): + if any(f.name == n for f in Employee._meta.fields): + return n + return None + + +PHONE_FIELD = _detect_phone_field() + + +class RegisterAPIView(APIView): + """ + POST /api/v1/auth/register/ + body: {email, password, first_name?, last_name?, phone?} + + Поведение: + - если email уже подтверждён → 400 email_taken + - если пользователь есть, но не подтверждён → перегенерируем код и шлём письмо (ok + resent=True) + - если нет пользователя → создаём, is_active=False, отправляем код (ok) + """ + + throttle_scope = "anon" + + @transaction.atomic + def post(self, request): + ser = RegisterSerializer(data=request.data) + ser.is_valid(raise_exception=True) + v = ser.validated_data + + email = v["email"].strip().lower() + password = v["password"] + + user = Employee.objects.filter(email__iexact=email).first() + if user and user.email_verified: + return Response({"ok": False, "error": "email_taken"}, status=400) + + if user and not user.email_verified: + user.email_activation_code = get_random_string(6, "0123456789") + user.is_active = False + user.set_password( + password + ) # обновим пароль, если вдруг был пустой/тестовый + if PHONE_FIELD and v.get("phone"): + norm = _normalize_phone(v["phone"]) + if norm: + setattr(user, PHONE_FIELD, norm) + if v.get("first_name"): + user.first_name = v["first_name"] + if v.get("last_name"): + user.last_name = v["last_name"] + user.save() + send_templated_mail( + subject="Подтверждение регистрации", + to=[user.email], + template_base="emails/registration_verify_code", + context={"code": user.email_activation_code, "user": user}, + ) + return Response({"ok": True, "resent": True}, status=200) + + # создаём нового + user = Employee( + email=email, + first_name=v.get("first_name", ""), + last_name=v.get("last_name", ""), + email_verified=False, + is_active=False, + email_activation_code=get_random_string(6, "0123456789"), ) + if PHONE_FIELD and v.get("phone"): + norm = _normalize_phone(v["phone"]) + if norm: + setattr(user, PHONE_FIELD, norm) + user.set_password(password) + user.save() - -class DepartmentViewSet(viewsets.ModelViewSet): - queryset = Department.objects.all() - serializer_class = DepartmentSerializer - permission_classes = [IsDepartmentHeadOrAdmin] - - def get_queryset(self): - user = self.request.user - if user.is_staff or user.is_superuser: - return Department.objects.all() - return Department.objects.filter(head=user) - - -class EmployeeActionViewSet(viewsets.ModelViewSet): - queryset = EmployeeAction.objects.all() - serializer_class = EmployeeActionSerializer - permission_classes = [IsHR | IsDepartmentHeadOrAdmin] - - -class EmployeePositionViewSet(viewsets.ModelViewSet): - queryset = EmployeePosition.objects.all() - serializer_class = EmployeePositionSerializer - permission_classes = [IsHR | IsDepartmentHeadOrAdmin] - - -class AbsenceViewSet(viewsets.ModelViewSet): - serializer_class = AbsenceSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - user = self.request.user - if user.is_staff or user.is_superuser: - return Absence.objects.all() - return Absence.objects.filter(employee=user) - - def perform_create(self, serializer): - serializer.save(employee=self.request.user) - - def perform_update(self, serializer): - if ( - serializer.instance.employee != self.request.user - and not self.request.user.is_staff - ): - raise PermissionDenied("Вы не можете редактировать чужие заявления.") - serializer.save() - - def perform_destroy(self, instance): - if instance.employee != self.request.user and not self.request.user.is_staff: - raise PermissionDenied("Вы не можете удалить чужое заявление.") - instance.delete() - - -class SkillViewSet(viewsets.ModelViewSet): - queryset = Skill.objects.all() - serializer_class = SkillSerializer - permission_classes = [permissions.IsAuthenticated] - - -class EducationViewSet(viewsets.ModelViewSet): - serializer_class = EducationSerializer - permission_classes = [permissions.IsAuthenticated] - - def get_queryset(self): - user = self.request.user - if user.is_staff: - return Education.objects.all() - return Education.objects.filter(employee=user) + send_templated_mail( + subject="Подтверждение регистрации", + to=[user.email], + template_base="emails/registration_verify_code", + context={"code": user.email_activation_code, "user": user}, + ) + return Response({"ok": True}, status=201) diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 41953ba9..55ec94cd 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -2,28 +2,20 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter from .calendar.viewsets import CompanyEventsViewSet, DepartmentEventsViewSet -from .employees.views import ( - AbsenceViewSet, - DepartmentViewSet, - EducationViewSet, - EmployeeActionViewSet, - EmployeePositionViewSet, - EmployeeViewSet, - SkillViewSet, -) +from .employees.views import (RegisterAPIView, ResendEmailAPIView, + VerifyEmailAPIView) router = DefaultRouter() -router.register(r"employees", EmployeeViewSet, basename="employee") -router.register(r"departments", DepartmentViewSet, basename="department") -router.register(r"actions", EmployeeActionViewSet, basename="action") -router.register(r"positions", EmployeePositionViewSet, basename="position") -router.register(r"absences", AbsenceViewSet, basename="absence") -router.register(r"skills", SkillViewSet, basename="skill") -router.register(r"educations", EducationViewSet, basename="education") router.register( r"calendar/company-events", CompanyEventsViewSet, basename="company-events" ) + +app_name = "api_v1" + urlpatterns = [ + path("auth/register/", RegisterAPIView.as_view(), name="register"), + path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"), + path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"), path( "calendar/departments//events/", DepartmentEventsViewSet.as_view({"get": "list"}), diff --git a/backend/common/__init__.py b/backend/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/common/emails.py b/backend/common/emails.py new file mode 100644 index 00000000..dcb4de9a --- /dev/null +++ b/backend/common/emails.py @@ -0,0 +1,44 @@ +# backend/common/emails.py +from pathlib import Path +from typing import Iterable, Mapping, Optional + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + + +def send_templated_mail( + subject: str, + to: Iterable[str], + template_base: str, + context: Mapping, + from_email: Optional[str] = None, + attachments: Optional[list[tuple[str, bytes, str]]] = None, +) -> int: + """ + Отправляет письмо в HTML + plain текстах по шаблонам: + templates/.html + templates/.txt (опционально) + attachments: список троек (name, content_bytes, mimetype) + """ + html_body = render_to_string(f"{template_base}.html", context) + # .txt файл опционален — если его нет, берём strip_tags от html + try: + text_body = render_to_string(f"{template_base}.txt", context) + except Exception: + text_body = strip_tags(html_body) + + email = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=from_email or settings.DEFAULT_FROM_EMAIL, + to=list(to), + ) + email.attach_alternative(html_body, "text/html") + + if attachments: + for name, data, mimetype in attachments: + email.attach(name, data, mimetype) + + return email.send(fail_silently=False) diff --git a/backend/employees/api_client.py b/backend/employees/api_client.py new file mode 100644 index 00000000..1441a157 --- /dev/null +++ b/backend/employees/api_client.py @@ -0,0 +1,61 @@ +# employees/api_client.py +from django.urls import reverse +from django.http import HttpRequest +import requests + + +class APIError(Exception): + pass + + +def _json_safe(x): + try: + from phonenumber_field.phonenumber import PhoneNumber + except Exception: + PhoneNumber = None + if isinstance(x, dict): + return {k: _json_safe(v) for k, v in x.items()} + if isinstance(x, (list, tuple)): + return [_json_safe(v) for v in x] + if PhoneNumber and isinstance(x, PhoneNumber): + return x.as_e164 or x.raw_input or str(x) + return x + + +def _abs_url(request: HttpRequest, url_name: str, **kwargs) -> str: + rel = reverse(url_name, kwargs=kwargs or None) + return request.build_absolute_uri(rel) + + +def api_post(request: HttpRequest, url_name: str, payload: dict, **kwargs): + url = _abs_url(request, url_name) + payload = _json_safe(payload or {}) + headers = {"Accept": "application/json"} + try: + resp = requests.post(url, json=payload, headers=headers, timeout=10, **kwargs) + except requests.RequestException as e: + raise APIError(f"api_network_error: {e}") + try: + data = resp.json() + except ValueError: + # ← Вот тут будем видеть «почему» + snippet = (resp.text or "")[:500] + raise APIError(f"api_bad_json status={resp.status_code} body={snippet!r}") + return resp.status_code, data + + +def api_get(request: HttpRequest, url_name: str, params: dict | None = None, **kwargs): + url = _abs_url(request, url_name) + headers = {"Accept": "application/json"} + try: + resp = requests.get( + url, params=params or {}, headers=headers, timeout=10, **kwargs + ) + except requests.RequestException as e: + raise APIError(f"api_network_error: {e}") + try: + data = resp.json() + except ValueError: + snippet = (resp.text or "")[:500] + raise APIError(f"api_bad_json status={resp.status_code} body={snippet!r}") + return resp.status_code, data diff --git a/backend/employees/forms.py b/backend/employees/forms.py index b1528d81..6cbcf3f1 100644 --- a/backend/employees/forms.py +++ b/backend/employees/forms.py @@ -4,8 +4,7 @@ from django.contrib.auth.forms import UserChangeForm, UserCreationForm from django.core.exceptions import ValidationError from phonenumber_field.formfields import PhoneNumberField -from .models import (Absence, Department, Education, Employee, - EmployeeDepartment, Skill) +from .models import Absence, Department, Education, Employee, EmployeeDepartment, Skill # ========================= @@ -30,6 +29,16 @@ class RegistrationForm(AvatarValidationMixin, UserCreationForm): last_name = forms.CharField(label="Фамилия", required=True) email = forms.EmailField(label="Email", required=True) avatar = forms.ImageField(label="Аватар", required=False) + birth_date = forms.DateField( + required=False, + widget=forms.DateInput( + attrs={ + "type": "date", + "placeholder": "Дата рождения", + "class": "form-control", + } + ), + ) class Meta: model = Employee @@ -106,10 +115,57 @@ class ProfileUpdateForm(AvatarValidationMixin, UserChangeForm): # ========================= -# SMS верификация +# Регистрация # ========================= -class SMSCodeVerifyForm(forms.Form): - code = forms.CharField(label="Код из SMS", max_length=6) +class RegistrationForm(AvatarValidationMixin, UserCreationForm): + first_name = forms.CharField(label="Имя", required=True) + last_name = forms.CharField(label="Фамилия", required=True) + email = forms.EmailField(label="Email", required=True) + avatar = forms.ImageField(label="Аватар", required=False) + birth_date = forms.DateField( + required=False, + widget=forms.DateInput( + attrs={ + "type": "date", + "placeholder": "Дата рождения", + "class": "form-control", + } + ), + ) + + class Meta: + model = Employee + fields = [ + "phone_number", + "last_name", + "first_name", + "patronymic", + "birth_date", + "whatsapp", + "telegram", + "wechat", + "email", + "avatar", + "password1", + "password2", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["whatsapp"].required = False + self.fields["telegram"].required = False + self.fields["wechat"].required = False + + def clean(self): + cleaned = super().clean() + # Более дружественная ошибка, чем из model.clean + if not ( + cleaned.get("whatsapp") or cleaned.get("telegram") or cleaned.get("wechat") + ): + raise ValidationError( + "Укажите хотя бы один контакт: WhatsApp, Telegram или WeChat." + ) + return cleaned # ========================= diff --git a/backend/employees/management/__init__.py b/backend/employees/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/employees/management/commands/__init__.py b/backend/employees/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/employees/management/commands/mailtest.py b/backend/employees/management/commands/mailtest.py new file mode 100644 index 00000000..24fccd43 --- /dev/null +++ b/backend/employees/management/commands/mailtest.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings +from common.emails import send_templated_mail + +class Command(BaseCommand): + help = "Отправить тестовое письмо на указанный адрес" + + def add_arguments(self, parser): + parser.add_argument("email", type=str) + + def handle(self, *args, **opts): + to_email = opts["email"] + try: + sent = send_templated_mail( + subject="Тестовое письмо", + to=[to_email], + template_base="emails/registration_verify_code", + context={"code": "123456", "user": None}, + ) + self.stdout.write(self.style.SUCCESS(f"Письмо отправлено ({sent}). Бэкенд: {settings.EMAIL_BACKEND}")) + except Exception as e: + raise CommandError(f"Ошибка отправки: {e}") diff --git a/backend/employees/migrations/0011_department_head_appointed_at_and_more.py b/backend/employees/migrations/0011_department_head_appointed_at_and_more.py new file mode 100644 index 00000000..107f95dd --- /dev/null +++ b/backend/employees/migrations/0011_department_head_appointed_at_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.4 on 2025-08-20 09:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0010_historicalemployeeaction_historicalemployeeposition'), + ] + + operations = [ + migrations.AddField( + model_name='department', + name='head_appointed_at', + field=models.DateTimeField(auto_now=True, verbose_name='Дата назначения руководителя'), + ), + migrations.AddField( + model_name='employee', + name='email_activation_code', + field=models.CharField(blank=True, max_length=6, null=True), + ), + migrations.AddField( + model_name='employee', + name='email_verified', + field=models.BooleanField(default=False, verbose_name='Email подтверждён'), + ), + migrations.AlterField( + model_name='employeeaction', + name='action', + field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'), + ), + migrations.AlterField( + model_name='historicalemployeeaction', + name='action', + field=models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=50, verbose_name='Кадровое событие'), + ), + migrations.AlterUniqueTogether( + name='employeedepartment', + unique_together={('employee', 'department')}, + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index 9c4a9a78..e277edcc 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -1,37 +1,89 @@ # backend\employees\models.py +from django.conf import settings from django.contrib.auth.models import AbstractUser, BaseUserManager from django.core.exceptions import ValidationError +from django.core.mail import send_mail from django.db import models from django.utils import timezone +from django.utils.crypto import get_random_string from phonenumber_field.modelfields import PhoneNumberField from simple_history.models import HistoricalRecords from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES +class DateRangeMixin(models.Model): + class Meta: + abstract = True + + def clean(self): + super().clean() + if hasattr(self, "date_from") and hasattr(self, "date_to"): + if self.date_from and self.date_to and self.date_from >= self.date_to: + raise ValidationError("Дата начала должна быть раньше даты окончания") + + class EmployeeManager(BaseUserManager): use_in_migrations = True - def create_user(self, phone_number, email, password=None, **extra_fields): + def create_user(self, credential, password=None, **extra_fields): + # Определяем, email ли это или phone + if "@" in credential: + email = self.normalize_email(credential) + phone_number = extra_fields.pop("phone_number", None) + else: + phone_number = credential + email = extra_fields.pop("email", None) + + if not email: + raise ValueError("Пользователь должен иметь email для подтверждения") if not phone_number: raise ValueError("Пользователь должен иметь номер телефона") - if not email: - raise ValueError("Пользователь должен иметь email") - email = self.normalize_email(email) - user = self.model(phone_number=phone_number, email=email, **extra_fields) + + user = self.model(email=email, phone_number=phone_number, **extra_fields) user.set_password(password) + user.email_verified = False # Требует подтверждения + user.email_activation_code = get_random_string( + 6, allowed_chars="0123456789" + ) # Криптостойкий код user.save(using=self._db) + + # Отправка confirmation email (в будущем асинхронно через bots/celery) + send_mail( + "Подтверждение регистрации", + f"Ваш код подтверждения: {user.email_activation_code}", + settings.DEFAULT_FROM_EMAIL, + [user.email], + fail_silently=False, + ) + return user - def create_superuser(self, phone_number, email, password=None, **extra_fields): + def create_superuser(self, credential, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) + extra_fields.setdefault("email_verified", True) # Суперюзер confirmed if not extra_fields.get("is_staff"): raise ValueError("Суперпользователь должен иметь is_staff=True") if not extra_fields.get("is_superuser"): raise ValueError("Суперпользователь должен иметь is_superuser=True") - return self.create_user(phone_number, email, password, **extra_fields) + return self.create_user(credential, password, **extra_fields) + + def get_active(self): + """ + Возвращает list активных сотрудников, оптимизированный: предварительная фильтрация в DB по полям, + затем Python-фильтр для сложной логики (actions). + """ + qs = ( + self.get_queryset() + .filter(email_verified=True, is_active=True) + .prefetch_related("actions") + .order_by("-id") + ) + return [ + e for e in qs if e.is_actually_active + ] # Финальный фильтр по actions в Python class Employee(AbstractUser): @@ -52,12 +104,16 @@ class Employee(AbstractUser): updated_at = models.DateTimeField(auto_now=True) skills = models.ManyToManyField("Skill", related_name="employees", blank=True) is_active = models.BooleanField(default=False) - sms_activation_code = models.CharField(max_length=6, blank=True, null=True) + sms_activation_code = models.CharField( + max_length=6, blank=True, null=True + ) # Для будущей SMS-активации в bots (fallback к email) + email_verified = models.BooleanField("Email подтверждён", default=False) + email_activation_code = models.CharField(max_length=6, blank=True, null=True) objects = EmployeeManager() - USERNAME_FIELD = "phone_number" - REQUIRED_FIELDS = ["first_name", "last_name", "email"] + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["first_name", "last_name", "phone_number"] class Meta: verbose_name = "Сотрудник" @@ -80,11 +136,6 @@ class Employee(AbstractUser): def __str__(self): return f"{self.last_name} {self.first_name}" - @classmethod - def get_active(cls): - qs = cls.objects.prefetch_related("actions").order_by("-created_at") - return [e for e in qs if e.is_actually_active] - @property def employment_status(self): last_action = self.actions.order_by("-date").first() @@ -94,8 +145,6 @@ class Employee(AbstractUser): @property def departments(self): - from employees.models import Department, EmployeeDepartment - # Отделы, где сотрудник активен department_ids = EmployeeDepartment.objects.filter( employee=self, is_active=True @@ -108,18 +157,30 @@ class Employee(AbstractUser): @property def is_actually_active(self): + if not self.email_verified: + return False # Неактивен без подтверждения email last_action = self.actions.order_by("-date").first() if not last_action: - return False + return self.is_active return last_action.action != ACTION_DISMISSED + def verify_email(self, code): + if self.email_activation_code == code: + self.email_verified = True + self.email_activation_code = None + self.is_active = True # Активируем после подтверждения + self.save() + return True + return False + class EmployeeAction(models.Model): - employee = models.ForeignKey( Employee, related_name="actions", on_delete=models.CASCADE ) - action = models.CharField("Кадровое событие", max_length=32, choices=ACTION_CHOICES) + action = models.CharField( + "Кадровое событие", max_length=50, choices=ACTION_CHOICES + ) # Увеличено для расширения date = models.DateTimeField("Дата действия") comment = models.TextField("Комментарий/причина", blank=True) extra = models.JSONField("Дополнительно", blank=True, null=True) @@ -145,6 +206,9 @@ class Department(models.Model): null=True, blank=True, ) + head_appointed_at = models.DateTimeField( + "Дата назначения руководителя", auto_now=True + ) created_at = models.DateTimeField(auto_now_add=True) class Meta: @@ -158,12 +222,12 @@ class Department(models.Model): def active_employees(self): """ Все активные сотрудники отдела (через EmployeeDepartment), - включая руководителя, если он есть. + включая руководителя, если он есть и активен. """ qs = self.employeedepartment.filter(is_active=True).select_related("employee") - employees = [ed.employee for ed in qs] + employees = [ed.employee for ed in qs if ed.employee.is_actually_active] - if self.head and self.head not in employees: + if self.head and self.head.is_actually_active and self.head not in employees: employees.append(self.head) return employees @@ -178,18 +242,21 @@ class Department(models.Model): is_active=True, date_from__gte=month_ago ).select_related("employee") - employees = [ed.employee for ed in qs] + employees = [ed.employee for ed in qs if ed.employee.is_actually_active] - # Добавляем руководителя, если он недавно назначен - if self.head: - head_joined = self.created_at >= month_ago - if head_joined and self.head not in employees: - employees.append(self.head) + # Добавляем руководителя, если он недавно назначен и активен + if ( + self.head + and self.head_appointed_at >= month_ago + and self.head.is_actually_active + and self.head not in employees + ): + employees.append(self.head) return employees -class EmployeeDepartment(models.Model): +class EmployeeDepartment(DateRangeMixin, models.Model): employee = models.ForeignKey( "Employee", on_delete=models.CASCADE, related_name="departments_links" ) @@ -202,7 +269,7 @@ class EmployeeDepartment(models.Model): is_active = models.BooleanField(default=True) class Meta: - unique_together = ("employee", "department", "date_from") + unique_together = ("employee", "department") verbose_name = "Принадлежность к отделу" verbose_name_plural = "Принадлежности к отделам" @@ -210,7 +277,7 @@ class EmployeeDepartment(models.Model): return f"{self.employee} в {self.department} ({self.role or 'сотрудник'})" -class EmployeePosition(models.Model): +class EmployeePosition(DateRangeMixin, models.Model): """ Опциональная модель для хранения названия должности. Не влияет на определение, в каком отделе сотрудник работает. @@ -231,6 +298,11 @@ class EmployeePosition(models.Model): verbose_name = "Должность сотрудника" verbose_name_plural = "Должности сотрудников" + def clean(self): + super().clean() + if not self.date_from: + raise ValidationError("Дата начала обязательна") + @property def is_active(self): return not self.date_to or self.date_to >= timezone.now().date() @@ -239,7 +311,7 @@ class EmployeePosition(models.Model): return f"{self.employee} — {self.title} ({self.department})" -class Absence(models.Model): +class Absence(DateRangeMixin, models.Model): ABSENCE_TYPE_CHOICES = [ ("vacation", "Отпуск"), ("sick_leave", "Больничный"), @@ -269,9 +341,18 @@ class Absence(models.Model): verbose_name = "Отсутствие сотрудника" verbose_name_plural = "Отсутствия сотрудников" + def clean(self): + super().clean() + if not self.date_from: + raise ValidationError("Дата начала обязательна") + if not self.date_to: + raise ValidationError("Дата окончания обязательна") + def __str__(self): return f"{self.employee} — {self.get_type_display()} ({self.date_from}–{self.date_to})" + # TODO: Интегрировать с requests_app для создания заявок на absence + class Skill(models.Model): name = models.CharField(max_length=100, unique=True) diff --git a/backend/employees/urls_front.py b/backend/employees/urls_front.py index 6e2f8da1..2939c8fb 100644 --- a/backend/employees/urls_front.py +++ b/backend/employees/urls_front.py @@ -7,8 +7,10 @@ app_name = "employees" urlpatterns = [ # --- Регистрация и активация --- path("register/", views.RegisterView.as_view(), name="register"), - path("sms-verify/", views.SMSVerifyView.as_view(), name="sms_verify"), - path("resend-sms/", views.resend_sms, name="resend_sms"), + # path("sms-verify/", views.SMSVerifyView.as_view(), name="sms_verify"), + path("email-verify/", views.EmailVerifyView.as_view(), name="email_verify"), + # path("resend-sms/", views.resend_sms, name="resend_sms"), + path("resend-email/", views.resend_email, name="resend_email"), # --- Профиль и сотрудники --- path("profile/", views.profile, name="profile"), diff --git a/backend/employees/views_front.py b/backend/employees/views_front.py index 6eeff797..4edb2b9e 100644 --- a/backend/employees/views_front.py +++ b/backend/employees/views_front.py @@ -1,13 +1,10 @@ # backend/employees/views_front.py -import os -import random from datetime import timedelta -import requests -from communications.models import Chat -from django.contrib import messages +from common.emails import send_templated_mail from django.contrib.auth import login +from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db.models import ( @@ -27,12 +24,17 @@ from django.http import HttpResponseNotAllowed from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.crypto import get_random_string from django.views import View -from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView -from dotenv import load_dotenv -from feed.constants import TYPE_DEPARTMENT, TYPE_EMPLOYEE +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + ListView, + UpdateView, +) +from feed.constants import TYPE_DEPARTMENT from feed.models import Comment, Post, PostLike -from phonenumber_field.phonenumber import to_python from .forms import ( AbsenceForm, @@ -43,119 +45,228 @@ from .forms import ( ProfileUpdateForm, RegistrationForm, SkillForm, - SMSCodeVerifyForm, ) from .models import ( Absence, Department, Education, Employee, - EmployeeAction, # ← добавлено + EmployeeAction, EmployeeDepartment, Skill, ) - -load_dotenv() - - -# ========================= -# Утилиты -# ========================= -def send_sms_alpha(phone: str, text: str, sender: str = "EUSRR"): - api_key = os.getenv("SMS_API_TOKEN", "") - api_url = os.getenv("SMS_API_URL", "https://new.smsgorod.ru/apiSms/create") - payload = { - "apiKey": api_key, - "sms": [{"channel": "char", "sender": sender, "text": text, "phone": phone}], - } - try: - resp = requests.post( - api_url, - json=payload, - headers={"Content-Type": "application/json"}, - timeout=5, - ) - return resp.json() - except Exception as e: - return {"error": str(e)} - - -def _annotated_feed(base_qs, user): - """ - Добавляет к queryset постов: - - is_liked: bool — лайкал ли текущий пользователь - - comments_count: int — количество комментариев - - last_comment_list: [Comment] длиной 1 с автором (последний) - Плюс select_related(author, department) и сортировка как в ленте. - """ - is_liked = ( - Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user)) - if user.is_authenticated - else Value(False, output_field=BooleanField()) - ) - last_comment_qs = Comment.objects.select_related("author").order_by("-created_at")[ - :1 - ] - return ( - base_qs.select_related("author", "department") - .annotate(is_liked=is_liked, comments_count=Count("comments")) - .prefetch_related( - Prefetch("comments", queryset=last_comment_qs, to_attr="last_comment_list") - ) - .order_by("-pinned", "-created_at") - ) +from django.http import HttpResponseNotAllowed +from employees.api_client import api_post, APIError # ========================= # Регистрация # ========================= + + class RegisterView(CreateView): model = Employee form_class = RegistrationForm template_name = "registration/register.html" - success_url = reverse_lazy("feed:feed_list") def form_valid(self, form): - user = form.save(commit=False) - user.is_active = True - user.save() - login(self.request, user, backend="django.contrib.auth.backends.ModelBackend") - return redirect("feed:feed_list") + cd = form.cleaned_data + + email = cd.get("email") + password = cd.get("password1") or cd.get("password") or "" + first_name = cd.get("first_name", "") + last_name = cd.get("last_name", "") + phone = cd.get("phone") or cd.get("phone_number") or "" + + # >>> добавь это приведение типов <<< + try: + # если установлен django-phonenumber-field + from phonenumber_field.phonenumber import PhoneNumber + except Exception: + PhoneNumber = None + + if PhoneNumber and isinstance(phone, PhoneNumber): + # canonical формат для API + phone = phone.as_e164 or phone.raw_input or str(phone) + elif phone is None: + phone = "" + else: + phone = str(phone).strip() + + payload = { + "email": email, + "password": password, + "first_name": first_name, + "last_name": last_name, + } + if phone: + payload["phone"] = phone + + try: + status_code, data = api_post(self.request, "api_v1:register", payload) + except APIError as e: + form.add_error(None, f"Ошибка регистрации: {e}") + return self.form_invalid(form) + + if status_code in (200, 201) and data.get("ok"): + self.request.session["email"] = email + if data.get("resent"): + messages.info(self.request, "Пользователь уже был создан. Мы отправили код повторно.") + else: + messages.success(self.request, "Код подтверждения отправлен на вашу почту.") + return redirect("employees:email_verify") + + err = (data or {}).get("error") + if err == "email_taken": + form.add_error("email", "Этот email уже используется.") + else: + form.add_error(None, "Не удалось зарегистрировать. Попробуйте позже.") + return self.form_invalid(form) -class SMSVerifyView(View): - template_name = "registration/sms_verify.html" +class EmailVerifyView(View): + template_name = "registration/email_verify.html" def get(self, request): return render( - request, self.template_name, {"phone": request.session.get("phone_number")} + request, self.template_name, {"email": request.session.get("email")} ) def post(self, request): - phone = request.session.get("phone_number") - if not phone: + email = request.session.get("email") + if not email: messages.error(request, "Сессия истекла. Пройдите регистрацию заново.") return redirect("employees:register") - user = Employee.objects.filter(phone_number=to_python(phone)).first() - if not user: + code = (request.POST.get("code") or "").strip() + if not code: + messages.error(request, "Введите код подтверждения.") + return render(request, self.template_name, {"email": email}) + + # ✨ зовём API + try: + status_code, data = api_post( + request, "api_v1:verify-email", {"email": email, "code": code} + ) + except APIError as e: + messages.error(request, f"Ошибка проверки кода: {e}") + return render(request, self.template_name, {"email": email}) + + if status_code == 200 and data.get("ok"): + # Автовход + user = None + user_id = data.get("user_id") + if user_id: + user = Employee.objects.filter(id=user_id).first() + if not user: + user = Employee.objects.filter(email__iexact=email).first() + + if user: + request.session.pop("email", None) + login( + request, user, backend="django.contrib.auth.backends.ModelBackend" + ) + messages.success(request, "Email подтверждён! Добро пожаловать.") + return redirect("feed:feed_list") + + messages.warning( + request, + "Email подтверждён, но не удалось выполнить вход. Войдите вручную.", + ) + return redirect("login") + + # Обработка ошибок API + err = (data or {}).get("error") + if err == "user_not_found": messages.error(request, "Пользователь не найден.") return redirect("employees:register") - - form = SMSCodeVerifyForm(request.POST) - if form.is_valid(): - if str(user.sms_activation_code) == str(form.cleaned_data["code"]): - user.is_active = True - user.sms_activation_code = None - user.save() - messages.success(request, "Телефон подтверждён! Можете войти.") - return redirect("login") + if err == "invalid_code": messages.error(request, "Код неверный.") - return render(request, self.template_name, {"form": form, "phone": phone}) + return render(request, self.template_name, {"email": email}) + if err == "empty_code": + messages.error(request, "Введите код подтверждения.") + return render(request, self.template_name, {"email": email}) + + messages.error(request, "Не удалось подтвердить email. Попробуйте позже.") + return render(request, self.template_name, {"email": email}) -def resend_sms(request): - return HttpResponseNotAllowed(["GET", "POST"], "Not Implemented") +def resend_email(request): + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + email = request.session.get("email") + if not email: + messages.error(request, "Сессия истекла.") + return redirect("employees:register") + + # Простая защита от частых повторов: не чаще 1 раза в минуту + # Локальная защита от частых повторов оставляем (по сессии) + now_ts = int(timezone.now().timestamp()) + last_ts = request.session.get("email_code_last_sent_ts") + if last_ts and (now_ts - int(last_ts) < 60): + messages.error(request, "Слишком часто. Попробуйте через минуту.") + return redirect("employees:email_verify") + # Вызываем API + try: + status_code, data = api_post( + request, + "api_v1:resend-email", + {"email": email}, + ) + except APIError as e: + messages.error(request, f"Ошибка отправки кода: {e}") + return redirect("employees:email_verify") + + if status_code == 200 and data.get("ok"): + request.session["email_code_last_sent_ts"] = now_ts + messages.success(request, "Код отправлен повторно.") + return redirect("employees:email_verify") + + # Обработка ошибок API + err = (data or {}).get("error") + if err == "user_not_found": + messages.error(request, "Пользователь не найден.") + return redirect("employees:register") + if err == "already_verified": + messages.error(request, "Email уже подтверждён.") + return redirect("login") + messages.error(request, "Не удалось отправить код. Попробуйте позже.") + return redirect("employees:email_verify") + + +# ========================= +# Утилиты +# ========================= +def _annotated_feed(base_qs, user): + """ + Добавляет к queryset постов: + - is_liked: bool — лайкал ли текущий пользователь + - comments_count: int — количество комментариев + - comments_sorted: [Comment] — все комментарии поста, отсортированные по -created_at + Плюс select_related(author, department) и сортировка как в ленте. + + В шаблоне используйте первый элемент: + {{ post.comments_sorted|first }} + """ + is_liked = ( + Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user)) + if getattr(user, "is_authenticated", False) + else Value(False, output_field=BooleanField()) + ) + last_comment_qs = Comment.objects.select_related("author").order_by("-created_at") + return ( + base_qs.select_related("author", "department") + .annotate(is_liked=is_liked, comments_count=Count("comments")) + .prefetch_related( + Prefetch( + "comments", + queryset=last_comment_qs, + to_attr="comments_sorted", + ) + ) + .order_by("-pinned", "-created_at") + ) # ========================= @@ -168,12 +279,11 @@ def profile(request): show_edit = False # кадровые события — видит только тот, у кого есть право - hr_history = ( - EmployeeAction.history - .filter(employee_id=employee.id) - .select_related("history_user") - .order_by("-history_date") - ) + hr_actions = [] + if request.user.has_perm("employees.view_employeeaction"): + hr_actions = EmployeeAction.objects.filter(employee=employee).order_by("-date")[ + :50 + ] emp_departments = employee.departments.order_by("name") if request.method == "POST": @@ -195,7 +305,7 @@ def profile(request): "own": True, "posts": posts, "show_edit": show_edit, - "hr_actions": hr_history, + "hr_actions": hr_actions, "emp_departments": emp_departments, }, ) @@ -210,12 +320,11 @@ def employee_detail(request, pk): posts = _annotated_feed(Post.objects.filter(author=employee), request.user)[:10] # кадровые события целевого сотрудника — только при наличии права - hr_history = ( - EmployeeAction.history - .filter(employee_id=employee.id) - .select_related("history_user") - .order_by("-history_date") - ) + hr_actions = [] + if request.user.has_perm("employees.view_employeeaction"): + hr_actions = EmployeeAction.objects.filter(employee=employee).order_by("-date")[ + :50 + ] emp_departments = employee.departments.order_by("name") return render( request, @@ -225,7 +334,7 @@ def employee_detail(request, pk): "own": False, "posts": posts, "show_edit": False, - "hr_actions": hr_history, # ← добавлено + "hr_actions": hr_actions, "emp_departments": emp_departments, }, ) @@ -258,6 +367,10 @@ class EmployeeListView(LoginRequiredMixin, ListView): # Текущий отдел (для head_id) department = Department.objects.filter(pk=dept_id).only("id", "head_id").first() + # Если отдел не найден, логично вернуть пустой список + if not department: + return qs.none() + # Активная связь сотрудника с отделом (для аннотаций) link_qs = EmployeeDepartment.objects.filter( department_id=dept_id, @@ -337,36 +450,28 @@ class DepartmentDetailView(LoginRequiredMixin, DetailView): .order_by("employee__last_name", "employee__first_name")[:10] ) - # --- Присоединились за 14 дней: максимум 10 - two_weeks_ago = timezone.now() - timedelta(days=14) - has_link_created = any( - f.name == "created_at" for f in EmployeeDepartment._meta.fields - ) - if has_link_created: - recent_links = base_links.filter(created_at__gte=two_weeks_ago).order_by( - "-created_at" - )[:10] - else: - recent_links = base_links.filter( - employee__created_at__gte=two_weeks_ago - ).order_by("-employee__created_at")[:10] + # --- Присоединились за 30 дней: максимум 10 (сравниваем DateField с датой) + month_ago_date = (timezone.now() - timedelta(days=30)).date() + recent_links = base_links.filter(date_from__gte=month_ago_date).order_by( + "-date_from" + )[:10] # --- Лента отдела с аннотациями (для сердца/счётчика/последнего коммента) is_liked_annot = ( Exists(PostLike.objects.filter(post=OuterRef("pk"), user=self.request.user)) if self.request.user.is_authenticated - else Value(False) + else Value(False, output_field=BooleanField()) ) last_comment_qs = Comment.objects.select_related("author").order_by( "-created_at" - )[:1] + ) posts = ( - Post.objects.filter(type="department", department=department) + Post.objects.filter(type=TYPE_DEPARTMENT, department=department) .select_related("author", "department") .annotate(is_liked=is_liked_annot, comments_count=Count("comments")) .prefetch_related( Prefetch( - "comments", queryset=last_comment_qs, to_attr="last_comment_list" + "comments", queryset=last_comment_qs, to_attr="comments_sorted" ) ) .order_by("-pinned", "-created_at") @@ -474,10 +579,14 @@ def invite_to_department(request, pk): employee = form.cleaned_data["employee"] emp_dep, created = EmployeeDepartment.objects.get_or_create( - employee=employee, department=department, defaults={"is_active": True} + employee=employee, + department=department, + defaults={"is_active": True, "date_from": timezone.now().date()}, ) if not created and not emp_dep.is_active: emp_dep.is_active = True + emp_dep.date_from = timezone.now().date() + emp_dep.date_to = None emp_dep.save() messages.success(request, f"{employee.get_full_name()} приглашён в отдел!") @@ -554,28 +663,28 @@ class AbsenceDeleteView(BaseCRUDDeleteView): success_url = reverse_lazy("employees:absence_list") -# Skill CRUD -class SkillListView(LoginRequiredMixin, ListView): +# Skill CRUD (через базовые вьюхи, чтобы ограничить видимость своими записями) +class SkillListView(BaseCRUDListView): model = Skill template_name = "employees/skill_list.html" context_object_name = "skills" -class SkillCreateView(LoginRequiredMixin, CreateView): +class SkillCreateView(BaseCRUDCreateView): model = Skill form_class = SkillForm template_name = "employees/skill_form.html" success_url = reverse_lazy("employees:skill_list") -class SkillUpdateView(LoginRequiredMixin, UpdateView): +class SkillUpdateView(BaseCRUDUpdateView): model = Skill form_class = SkillForm template_name = "employees/skill_form.html" success_url = reverse_lazy("employees:skill_list") -class SkillDeleteView(LoginRequiredMixin, DeleteView): +class SkillDeleteView(BaseCRUDDeleteView): model = Skill template_name = "employees/skill_confirm_delete.html" success_url = reverse_lazy("employees:skill_list") diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py new file mode 100644 index 00000000..bb89da2f --- /dev/null +++ b/backend/eusrr_backend/auth_backends.py @@ -0,0 +1,98 @@ +# eusrr_backend/auth_backends.py +import re +from typing import Optional + +from django.contrib.auth.backends import ModelBackend +from django.conf import settings + +from employees.models import Employee # если модель в другом месте — поправь импорт + +# phonenumbers — опционально, но очень желательно: pip install phonenumbers +try: + import phonenumbers + from phonenumbers import PhoneNumberFormat +except Exception: + phonenumbers = None + PhoneNumberFormat = None + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def _looks_like_email(s: str) -> bool: + return bool(EMAIL_RE.match(s or "")) + + +def _normalize_phone(raw: str) -> Optional[str]: + """ + Приводим номер к формату E.164 (+79991234567). + Возвращаем None, если распарсить не удалось или пакет не установлен. + """ + if not raw: + return None + if phonenumbers is None: + # fallback — без нормализации + return str(raw).strip() + region = getattr(settings, "PHONE_DEFAULT_REGION", "RU") + try: + pn = phonenumbers.parse(str(raw), region) + if not phonenumbers.is_valid_number(pn): + return None + return phonenumbers.format_number(pn, PhoneNumberFormat.E164) + except Exception: + return None + + +def _detect_phone_field() -> Optional[str]: + """ + Определяем имя поля телефона в модели Employee. + Поддерживаем распространённые варианты. + """ + candidates = ("phone", "phone_number", "mobile", "msisdn", "tel") + field_names = {f.name for f in Employee._meta.get_fields()} + for name in candidates: + if name in field_names: + return name + return None + + +PHONE_FIELD = _detect_phone_field() + + +class EmailOrPhoneBackend(ModelBackend): + """ + Аутентификация по email ИЛИ по телефону (E.164) + стандартная проверка пароля. + Пользователь должен быть активен (is_active=True) — активируйте после verify_email(). + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + login = username or kwargs.get("email") or kwargs.get("phone") + if not login or not password: + return None + + user: Optional[Employee] = None + + # 1) Email + if _looks_like_email(login): + user = Employee.objects.filter(email__iexact=login).first() + else: + # 2) Phone + if PHONE_FIELD: + normalized = _normalize_phone(login) + # сначала пробуем нормализованный E.164 + if normalized: + user = Employee.objects.filter(**{PHONE_FIELD: normalized}).first() + # если не нашли — пробуем как ввёл пользователь (на случай старых данных) + if not user: + raw = str(login).strip() + user = Employee.objects.filter(**{PHONE_FIELD: raw}).first() + + if not user: + return None + + # допускаем логин только активных (email подтверждён → is_active=True) + if not user.is_active: + return None + + if user.check_password(password): + return user + return None diff --git a/backend/eusrr_backend/forms_auth.py b/backend/eusrr_backend/forms_auth.py new file mode 100644 index 00000000..b4525ca3 --- /dev/null +++ b/backend/eusrr_backend/forms_auth.py @@ -0,0 +1,20 @@ +# backend/employees/forms_auth.py +from django import forms +from django.contrib.auth.forms import AuthenticationForm + + +class EmailOrPhoneAuthenticationForm(AuthenticationForm): + username = forms.CharField( + label="Email или телефон", + widget=forms.TextInput( + attrs={ + "autofocus": True, + "placeholder": "user@example.com или +7 999 123-45-67", + } + ), + ) + password = forms.CharField( + label="Пароль", + strip=False, + widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}), + ) diff --git a/backend/eusrr_backend/middleware.py b/backend/eusrr_backend/middleware.py index df36cc2d..b00079c8 100644 --- a/backend/eusrr_backend/middleware.py +++ b/backend/eusrr_backend/middleware.py @@ -11,9 +11,14 @@ class AuthRequiredMiddleware: def __call__(self, request): allowed_paths = [ reverse('login'), - reverse('register'), - reverse('sms_verify'), - reverse('resend_sms') + reverse('employees:register'), + reverse('employees:email_verify'), + reverse('employees:resend_email'), + reverse('api_v1:register'), + reverse('api_v1:resend-email'), + reverse('api_v1:verify-email') + + ] if not request.user.is_authenticated and request.path not in allowed_paths: return redirect('login') diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 5e9e91e7..1417bd76 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -8,11 +8,10 @@ load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent -SECRET_KEY = "django-insecure--kftc-d47!6m2ib6jof3^d%n1&4k%mdf33brejsyg*@il#$-52" +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure-key") +DEBUG = os.getenv("DEBUG", "true").lower() == "true" +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") -DEBUG = True - -ALLOWED_HOSTS = ["localhost", "127.0.0.1"] INSTALLED_APPS = [ @@ -28,6 +27,7 @@ INSTALLED_APPS = [ "django.contrib.postgres", "widget_tweaks", "simple_history", + "rest_framework", "employees.apps.EmployeesConfig", "api.apps.ApiConfig", "hikcentral.apps.HikcentralConfig", @@ -139,13 +139,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "employees.Employee" -LOGIN_URL = "register" +LOGIN_URL = "login" LOGIN_REDIRECT_URL = "/" # LOGOUT_REDIRECT_URL = 'login' REGISTRATION_AUTO_LOGIN = True +PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 - -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +PHONE_DEFAULT_REGION = os.getenv("PHONE_DEFAULT_REGION", "RU") MEDIA_URL = "/media/" MEDIA_ROOT = os.path.join(BASE_DIR, "media/") @@ -199,10 +199,21 @@ CSRF_TRUSTED_ORIGINS = os.getenv( "CSRF_TRUSTED_ORIGINS", default="https://*.sytes.net" ).split(",") -ACCOUNT_EMAIL_VERIFICATION = "mandatory" # для allauth, если вдруг используешь -DEFAULT_FROM_EMAIL = "noreply@yourdomain.com" +EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") +EMAIL_HOST = os.getenv("EMAIL_HOST", "") +EMAIL_PORT = int(os.getenv("EMAIL_PORT", "465")) +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "false").lower() == "true" +EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "true").lower() == "true" +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER or "webmaster@localhost") ASGI_APPLICATION = "eusrr_backend.asgi.application" +AUTHENTICATION_BACKENDS = [ + "eusrr_backend.auth_backends.EmailOrPhoneBackend", + "django.contrib.auth.backends.ModelBackend", +] + CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", @@ -211,3 +222,20 @@ CHANNEL_LAYERS = { }, } } + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", # куки/сессия + # "rest_framework.authentication.BasicAuthentication", # опц., только в DEV + # "rest_framework_simplejwt.authentication.JWTAuthentication", # если решишь добавить JWT + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny" # оставим как у тебя для плавной миграции + ], + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "60/min", + }, +} diff --git a/backend/eusrr_backend/urls.py b/backend/eusrr_backend/urls.py index 2b7f707c..89deca2d 100644 --- a/backend/eusrr_backend/urls.py +++ b/backend/eusrr_backend/urls.py @@ -4,14 +4,34 @@ from django.contrib import admin from django.urls import include, path, reverse_lazy from django.views.generic.edit import CreateView from employees.forms import RegistrationForm -from employees.views_front import RegisterView, SMSVerifyView, resend_sms +from employees.views_front import RegisterView + +from . import views_auth urlpatterns = [ path("admin/", admin.site.urls), - path("auth/", include("django.contrib.auth.urls")), - path("auth/register/", RegisterView.as_view(), name="register"), - path("sms-verify/", SMSVerifyView.as_view(), name="sms_verify"), - path("resend-sms/", resend_sms, name="resend_sms"), + path("auth/login/", views_auth.EmailOrPhoneLoginView.as_view(), name="login"), + path("auth/logout/", views_auth.ConfirmLogoutView.as_view(), name="logout"), + path("auth/password-reset/", views_auth.PasswordResetView.as_view( + template_name="registration/password_reset_form.html", + email_template_name="registration/password_reset_email.txt", + subject_template_name="registration/password_reset_subject.txt", + success_url="/auth/password-reset/done/", + ), name="password_reset"), + path("auth/password-reset/done/", views_auth.PasswordResetDoneView.as_view( + template_name="registration/password_reset_done.html", + ), name="password_reset_done"), + path("auth/reset///", views_auth.PasswordResetConfirmView.as_view( + template_name="registration/password_reset_confirm.html", + success_url="/auth/reset/done/", + ), name="password_reset_confirm"), + path("auth/reset/done/", views_auth.PasswordResetCompleteView.as_view( + template_name="registration/password_reset_complete.html", + ), name="password_reset_complete"), + # path("auth/", include("django.contrib.auth.urls")), + # path("auth/register/", RegisterView.as_view(), name="register"), + # path("sms-verify/", SMSVerifyView.as_view(), name="sms_verify"), + # path("resend-sms/", resend_sms, name="resend_sms"), path("calendar/", include("calendar_app.urls", namespace="calendar")), path("documents/", include("documents.urls", namespace="documents")), path("requests/", include("requests_app.urls", namespace="requests_app")), diff --git a/backend/eusrr_backend/views_auth.py b/backend/eusrr_backend/views_auth.py new file mode 100644 index 00000000..0fd8b215 --- /dev/null +++ b/backend/eusrr_backend/views_auth.py @@ -0,0 +1,77 @@ +# employees/views_auth.py +from django.contrib import messages +from django.contrib.auth import logout +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.views import ( + LoginView as DjangoLoginView, + PasswordResetView as DjangoPasswordResetView, + PasswordResetDoneView as DjangoPasswordResetDoneView, + PasswordResetConfirmView as DjangoPasswordResetConfirmView, + PasswordResetCompleteView as DjangoPasswordResetCompleteView, +) +from django.shortcuts import redirect, render +from django.views import View + +from .forms_auth import EmailOrPhoneAuthenticationForm + + +class EmailOrPhoneLoginView(DjangoLoginView): + """ + Страница входа: одно поле 'Email или телефон' + пароль. + Использует наш кастомный AuthenticationForm. + """ + + template_name = "registration/login.html" + authentication_form = EmailOrPhoneAuthenticationForm + + +class ConfirmLogoutView(LoginRequiredMixin, View): + template_name = "registration/logout.html" # или logout_confirm.html — под твой файл + + def get(self, request): + next_url = request.GET.get("next") or request.META.get("HTTP_REFERER") or "" + return render(request, self.template_name, {"next": next_url}) + + def post(self, request): + next_url = request.POST.get("next") or "login" + logout(request) + messages.success(request, "Вы вышли из аккаунта.") + return redirect(next_url) + +# ===== Поток сброса пароля ===== + + +class PasswordResetView(DjangoPasswordResetView): + """ + Шаг 1: форма запроса сброса пароля по email. + """ + + template_name = "registration/password_reset_form.html" + email_template_name = "registration/password_reset_email.txt" + subject_template_name = "registration/password_reset_subject.txt" + success_url = "/auth/password-reset/done/" + + +class PasswordResetDoneView(DjangoPasswordResetDoneView): + """ + Шаг 2: уведомление, что письмо отправлено. + """ + + template_name = "registration/password_reset_done.html" + + +class PasswordResetConfirmView(DjangoPasswordResetConfirmView): + """ + Шаг 3: установка нового пароля по ссылке из письма. + """ + + template_name = "registration/password_reset_confirm.html" + success_url = "/auth/reset/done/" + + +class PasswordResetCompleteView(DjangoPasswordResetCompleteView): + """ + Шаг 4: пароль изменён. + """ + + template_name = "registration/password_reset_complete.html" diff --git a/backend/feed/views.py b/backend/feed/views.py index 7445e62d..ee3ba873 100644 --- a/backend/feed/views.py +++ b/backend/feed/views.py @@ -89,7 +89,7 @@ def feed_list(request): reverse=True, ) new_count = max(1, int(len(active_employees) * 0.1)) - new_employees = Employee.get_active()[:new_count] + new_employees = Employee.objects.get_active()[:new_count] return render( request, diff --git a/backend/templates/emails/registration_verify_code.html b/backend/templates/emails/registration_verify_code.html new file mode 100644 index 00000000..7591e79c --- /dev/null +++ b/backend/templates/emails/registration_verify_code.html @@ -0,0 +1,14 @@ + + + +

Подтверждение регистрации

+

Здравствуйте{{ user.get_full_name|default_if_none:"" }}!

+

Ваш код подтверждения:

+
+ {{ code }} +
+

Скопируйте код и введите на сайте, чтобы завершить регистрацию.

+
+

Если вы не запрашивали этот код, просто игнорируйте письмо.

+ + diff --git a/backend/templates/includes/navbar.html b/backend/templates/includes/navbar.html index 095d4840..d7127ee9 100644 --- a/backend/templates/includes/navbar.html +++ b/backend/templates/includes/navbar.html @@ -120,18 +120,15 @@
  • Профиль
  • -
    - {% csrf_token %} - -
    + + Выйти +
  • {% else %} Войти - Регистрация + Регистрация {% endif %} diff --git a/backend/templates/registration/email_verify.html b/backend/templates/registration/email_verify.html new file mode 100644 index 00000000..4d6049de --- /dev/null +++ b/backend/templates/registration/email_verify.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block extra_css %} + + +{% endblock %} + +{% block content %} +
    +
    Верификация email
    +
    + Мы отправили код на ваш email {{ email }}. Введите его ниже для подтверждения. +
    +
    + {% csrf_token %} + + + +
    + Отправить код заново +
    +{% endblock %} + +{% block extra_js %} + + +{% endblock %} \ No newline at end of file diff --git a/backend/templates/registration/login.html b/backend/templates/registration/login.html index bb44f718..1c2914c1 100644 --- a/backend/templates/registration/login.html +++ b/backend/templates/registration/login.html @@ -30,11 +30,11 @@ name="username" class="form-control {% if form.username.errors %}is-invalid{% endif %}" id="id_username" - placeholder="+7 (999) 123-45-67" + value="{{ form.username.value|default:'' }}" required autofocus> - + {% if form.username.errors %}
    {{ form.username.errors|striptags }} diff --git a/backend/templates/registration/logout.html b/backend/templates/registration/logout.html index d2ffab58..e132520a 100644 --- a/backend/templates/registration/logout.html +++ b/backend/templates/registration/logout.html @@ -1,33 +1,171 @@ -{% extends 'base.html' %} -{% load bootstrap5 %} +{# templates/registration/logout.html #} +{% extends "base.html" %} +{% load static %} -{% block title %}Выход из системы{% endblock %} +{% block title %}Выход{% endblock %} + +{% block extra_css %} + +{% endblock %} {% block content %} -
    -
    -
    -
    -

    Выход из системы

    -
    - -
    -
    - {% csrf_token %} - -
    -

    Вы уверены, что хотите выйти из системы?

    -
    - -
    - - Остаться -
    -
    -
    -
    + {# Перекрываем основной контент полноэкранным диалогом #} + +
    +{% endblock %} + +{% block extra_js %} + {% endblock %} diff --git a/backend/templates/registration/logout_confirm.html b/backend/templates/registration/logout_confirm.html new file mode 100644 index 00000000..ada01c1f --- /dev/null +++ b/backend/templates/registration/logout_confirm.html @@ -0,0 +1,33 @@ +{% load static %} + + + + + Подтверждение выхода + + + + + +
    +
    +

    Выйти из аккаунта?

    +

    Вы уверены, что хотите завершить сеанс?

    + +
    + {% csrf_token %} + {% if next %} + + {% else %} + + {% endif %} + +
    + Отмена + +
    +
    +
    +
    + + diff --git a/backend/templates/registration/password_reset_complete.htm b/backend/templates/registration/password_reset_complete.htm new file mode 100644 index 00000000..c65e60eb --- /dev/null +++ b/backend/templates/registration/password_reset_complete.htm @@ -0,0 +1,2 @@ +

    Пароль изменён

    +Войти diff --git a/backend/templates/registration/password_reset_email.txt b/backend/templates/registration/password_reset_email.txt new file mode 100644 index 00000000..2375a38e --- /dev/null +++ b/backend/templates/registration/password_reset_email.txt @@ -0,0 +1,6 @@ +Вы запросили сброс пароля на {{ site_name }}. + +Перейдите по ссылке для смены пароля: +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} + +Если вы не запрашивали сброс пароля, просто игнорируйте это письмо. diff --git a/backend/templates/registration/password_reset_form.html b/backend/templates/registration/password_reset_form.html new file mode 100644 index 00000000..ce9987c0 --- /dev/null +++ b/backend/templates/registration/password_reset_form.html @@ -0,0 +1,7 @@ +
    + {% csrf_token %} +

    Сброс пароля

    +

    Укажите e-mail вашей учетной записи.

    + {{ form.email.label_tag }} {{ form.email }} + +
    diff --git a/backend/templates/registration/password_reset_subject.txt b/backend/templates/registration/password_reset_subject.txt new file mode 100644 index 00000000..9f7c3920 --- /dev/null +++ b/backend/templates/registration/password_reset_subject.txt @@ -0,0 +1 @@ +Сброс пароля на {{ site_name }} diff --git a/backend/templates/registration/register.html b/backend/templates/registration/register.html index 4401cad7..ca771a59 100644 --- a/backend/templates/registration/register.html +++ b/backend/templates/registration/register.html @@ -26,22 +26,28 @@
    - -
    -
    + +
    +
    {% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
    -
    +
    {% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
    +
    + {% bootstrap_field form.wechat show_label=False placeholder="WeChat" %} + + + +
    {% if form.non_field_errors %} -
    +
    {% for error in form.non_field_errors %} {{ error }} {% endfor %}