feat(api): регистрация/верификация через API, whitelist в middleware, iOS-экран подтверждения выхода
**Что сделано**
* **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`).
**Миграции**: нет.
This commit is contained in:
parent
b401e86249
commit
5af1f9ec91
@ -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")
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
@ -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)
|
||||
|
||||
@ -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/<int:pk>/events/",
|
||||
DepartmentEventsViewSet.as_view({"get": "list"}),
|
||||
|
||||
0
backend/common/__init__.py
Normal file
0
backend/common/__init__.py
Normal file
44
backend/common/emails.py
Normal file
44
backend/common/emails.py
Normal file
@ -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/<template_base>.html
|
||||
templates/<template_base>.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)
|
||||
61
backend/employees/api_client.py
Normal file
61
backend/employees/api_client.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
# =========================
|
||||
|
||||
0
backend/employees/management/__init__.py
Normal file
0
backend/employees/management/__init__.py
Normal file
0
backend/employees/management/commands/__init__.py
Normal file
0
backend/employees/management/commands/__init__.py
Normal file
22
backend/employees/management/commands/mailtest.py
Normal file
22
backend/employees/management/commands/mailtest.py
Normal file
@ -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}")
|
||||
@ -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')},
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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")
|
||||
|
||||
98
backend/eusrr_backend/auth_backends.py
Normal file
98
backend/eusrr_backend/auth_backends.py
Normal file
@ -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
|
||||
20
backend/eusrr_backend/forms_auth.py
Normal file
20
backend/eusrr_backend/forms_auth.py
Normal file
@ -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"}),
|
||||
)
|
||||
@ -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')
|
||||
|
||||
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
@ -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/<uidb64>/<token>/", 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")),
|
||||
|
||||
77
backend/eusrr_backend/views_auth.py
Normal file
77
backend/eusrr_backend/views_auth.py
Normal file
@ -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"
|
||||
@ -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,
|
||||
|
||||
14
backend/templates/emails/registration_verify_code.html
Normal file
14
backend/templates/emails/registration_verify_code.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<body style="font-family:Arial,Helvetica,sans-serif; line-height:1.5; color:#111;">
|
||||
<h2>Подтверждение регистрации</h2>
|
||||
<p>Здравствуйте{{ user.get_full_name|default_if_none:"" }}!</p>
|
||||
<p>Ваш код подтверждения:</p>
|
||||
<div style="font-size:24px; font-weight:700; letter-spacing:4px; padding:12px 0;">
|
||||
{{ code }}
|
||||
</div>
|
||||
<p>Скопируйте код и введите на сайте, чтобы завершить регистрацию.</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:16px 0;">
|
||||
<p style="font-size:12px; color:#666;">Если вы не запрашивали этот код, просто игнорируйте письмо.</p>
|
||||
</body>
|
||||
</html>
|
||||
@ -120,18 +120,15 @@
|
||||
<li><a class="dropdown-item" href="{% url 'employees:profile' %}">
|
||||
<i class="bi-person me-2"></i>Профиль</a></li>
|
||||
<li>
|
||||
<form action="{% url 'logout' %}" method="post" class="m-0 p-0">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi-box-arrow-right me-2"></i>Выйти
|
||||
</button>
|
||||
</form>
|
||||
<a class="dropdown-item" href="{% url 'logout' %}">
|
||||
<i class="bi-box-arrow-right me-2"></i>Выйти
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="btn btn-outline-light rounded-pill me-2" href="{% url 'login' %}">Войти</a>
|
||||
<a class="btn btn-light rounded-pill" href="{% url 'register' %}">Регистрация</a>
|
||||
<a class="btn btn-light rounded-pill" href="{% url 'employees:register' %}">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
103
backend/templates/registration/email_verify.html
Normal file
103
backend/templates/registration/email_verify.html
Normal file
@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- Стили для iOS-подобного вида: светлый фон, закругленные элементы, поддержка тем -->
|
||||
<style>
|
||||
body {
|
||||
background-color: #f0f2f5; /* Light mode */
|
||||
color: #050505;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
body.dark-mode {
|
||||
background-color: #18191a; /* Dark mode */
|
||||
color: #e4e6eb;
|
||||
}
|
||||
.verify-container {
|
||||
max-width: 400px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 12px; /* Закругленные углы как в iOS */
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
body.dark-mode .verify-container {
|
||||
background-color: #242526;
|
||||
}
|
||||
.verify-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.verify-description {
|
||||
font-size: 16px;
|
||||
color: #606770;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
body.dark-mode .verify-description {
|
||||
color: #b0b3b8;
|
||||
}
|
||||
.verify-form input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #dddfe2;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 16px;
|
||||
background-color: white;
|
||||
color: #050505;
|
||||
}
|
||||
body.dark-mode .verify-form input {
|
||||
background-color: #3a3b3c;
|
||||
color: #e4e6eb;
|
||||
border: 1px solid #3a3b3c;
|
||||
}
|
||||
.verify-button {
|
||||
background-color: #1877f2; /* Синий как в Facebook/Instagram */
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.verify-button:hover {
|
||||
background-color: #166fe5;
|
||||
}
|
||||
.resend-link {
|
||||
display: block;
|
||||
margin-top: 15px;
|
||||
color: #1877f2;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="verify-container">
|
||||
<div class="verify-title">Верификация email</div>
|
||||
<div class="verify-description">
|
||||
Мы отправили код на ваш email {{ email }}. Введите его ниже для подтверждения.
|
||||
</div>
|
||||
<form method="post" class="verify-form">
|
||||
{% csrf_token %}
|
||||
<label for="id_code">Код верификации</label>
|
||||
<input type="text" name="code" id="id_code" maxlength="6" placeholder="Введите код" required>
|
||||
<button type="submit" class="verify-button">Подтвердить</button>
|
||||
</form>
|
||||
<a href="{% url 'employees:resend_email' %}" class="resend-link">Отправить код заново</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- JS для фокуса на поле и анимаций -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelector('input[name="code"]').focus();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -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>
|
||||
<label for="id_username">Номер телефона</label>
|
||||
<label for="id_username">Номер телефона или email</label>
|
||||
{% if form.username.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ form.username.errors|striptags }}
|
||||
|
||||
@ -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 %}
|
||||
<style>
|
||||
/* Overlay поверх всего (и навбара тоже) */
|
||||
.ios-overlay{
|
||||
position: fixed; inset: 0; z-index: 1080;
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 35%);
|
||||
backdrop-filter: blur(10px) saturate(110%);
|
||||
display: grid; place-items: end center; /* bottom sheet по умолчанию */
|
||||
}
|
||||
@media (min-width: 992px){
|
||||
.ios-overlay{ place-items: center; }
|
||||
}
|
||||
|
||||
/* Bottom sheet / Card */
|
||||
.ios-sheet{
|
||||
width: 100%; max-width: 560px;
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
|
||||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 12px 48px rgba(0,0,0,.28);
|
||||
animation: slideUp .28s ease both;
|
||||
}
|
||||
@media (min-width: 992px){
|
||||
.ios-sheet{
|
||||
border-radius: 18px; animation: popIn .18s ease both;
|
||||
}
|
||||
}
|
||||
|
||||
/* Грип-бар как в iOS */
|
||||
.ios-grip{
|
||||
width: 36px; height: 4px; border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bs-body-color), transparent 70%);
|
||||
margin: .5rem auto 0.25rem auto;
|
||||
}
|
||||
|
||||
/* Контент */
|
||||
.ios-body{ padding: 1rem 1.25rem 1.25rem 1.25rem; }
|
||||
@media (min-width: 992px){ .ios-body{ padding: 1.25rem 1.5rem 1.5rem 1.5rem; } }
|
||||
|
||||
.ios-title{
|
||||
margin: .25rem 0 .25rem 0; text-align: center;
|
||||
font-weight: 800; letter-spacing: .2px;
|
||||
}
|
||||
.ios-sub{
|
||||
text-align: center; color: var(--bs-secondary-color);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
/* Аватар + иконка выхода */
|
||||
.ios-ava{
|
||||
width: 64px; height: 64px; border-radius: 999px; overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||||
background: var(--bs-tertiary-bg);
|
||||
display: grid; place-items: center; margin: .25rem auto .75rem auto;
|
||||
}
|
||||
.ios-ava img{ width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.ios-ico{
|
||||
width: 56px; height: 56px; border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--bs-danger), #000 12%);
|
||||
color: #fff; display: grid; place-items: center; margin: .25rem auto .75rem auto;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.ios-actions{ display: grid; gap: .6rem; }
|
||||
.ios-btn{ min-height: 48px; border-radius: 12px; font-weight: 700; }
|
||||
.ios-btn-danger{
|
||||
background: var(--bs-danger); border-color: var(--bs-danger); color: #fff;
|
||||
}
|
||||
.ios-btn-danger:hover{ filter: brightness(1.02); }
|
||||
.ios-btn-ghost{
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
|
||||
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
.ios-btn-ghost:hover{ background: color-mix(in srgb, var(--bs-body-bg), #fff 9%); }
|
||||
|
||||
/* Анимации */
|
||||
@keyframes slideUp{
|
||||
from{ transform: translateY(16px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes popIn{
|
||||
from{ transform: scale(.98); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h3 class="mb-0">Выход из системы</h3>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<p class="lead">Вы уверены, что хотите выйти из системы?</p>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-danger btn-lg">
|
||||
<i class="bi bi-power me-2"></i>Выйти
|
||||
</button>
|
||||
<a href="{% url 'dashboard' %}" class="btn btn-outline-secondary btn-lg">Остаться</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{# Перекрываем основной контент полноэкранным диалогом #}
|
||||
<div class="ios-overlay" role="dialog" aria-modal="true" aria-labelledby="logoutTitle" aria-describedby="logoutText">
|
||||
<div class="ios-sheet">
|
||||
<div class="ios-grip" aria-hidden="true"></div>
|
||||
<div class="ios-body">
|
||||
{# аватар если есть, иначе красивая иконка выхода #}
|
||||
{% if user.avatar %}
|
||||
<div class="ios-ava">
|
||||
<img src="{{ user.avatar.url }}" alt="">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="ios-ico">
|
||||
<i class="bi-box-arrow-right fs-3"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h1 id="logoutTitle" class="ios-title">Выйти из аккаунта?</h1>
|
||||
<p id="logoutText" class="ios-sub">
|
||||
{{ user.get_short_name|default:"Вы" }}, вы уверены, что хотите завершить сеанс?<br>
|
||||
Вы всегда можете войти снова — данные не удаляются.
|
||||
</p>
|
||||
|
||||
<form method="post" class="ios-actions" autocomplete="off">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ next|default:'' }}">
|
||||
|
||||
|
||||
<button type="submit" class="btn ios-btn ios-btn-danger" id="confirmBtn">
|
||||
Выйти
|
||||
</button>
|
||||
|
||||
<a href="javascript:void(0)" class="btn ios-btn ios-btn-ghost text-decoration-none text-center" id="cancelBtn">
|
||||
Отмена
|
||||
</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// UX: ESC = отмена, Enter на «Выйти», тап по фону = отмена
|
||||
(function(){
|
||||
const cancel = document.getElementById('cancelBtn');
|
||||
const confirm = document.getElementById('confirmBtn');
|
||||
const overlay = document.querySelector('.ios-overlay');
|
||||
|
||||
function goBack(){
|
||||
// если есть referer/next — туда, иначе на главную
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = (params.get('next') || '').trim();
|
||||
if (next) { location.href = next; return; }
|
||||
if (document.referrer) { history.back(); return; }
|
||||
location.href = "{% url 'login' %}";
|
||||
}
|
||||
|
||||
cancel?.addEventListener('click', goBack);
|
||||
|
||||
// клик по фону закрывает (но не по самой карточке)
|
||||
overlay?.addEventListener('click', (e)=>{
|
||||
if (e.target === overlay) goBack();
|
||||
});
|
||||
|
||||
addEventListener('keydown', (e)=>{
|
||||
if (e.key === 'Escape'){ e.preventDefault(); goBack(); }
|
||||
if (e.key === 'Enter' && document.activeElement !== cancel){
|
||||
// даём нативной форме отправиться
|
||||
}
|
||||
});
|
||||
|
||||
// автофокус на «Выйти»
|
||||
setTimeout(()=> confirm?.focus(), 10);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
33
backend/templates/registration/logout_confirm.html
Normal file
33
backend/templates/registration/logout_confirm.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Подтверждение выхода</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- если используешь bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="container py-5">
|
||||
<div class="card shadow-sm mx-auto" style="max-width:480px;">
|
||||
<div class="card-body">
|
||||
<h1 class="h4 mb-3">Выйти из аккаунта?</h1>
|
||||
<p class="text-secondary mb-4">Вы уверены, что хотите завершить сеанс?</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% else %}
|
||||
<input type="hidden" name="next" value="{% url 'login' %}">
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="javascript:history.back()" class="btn btn-outline-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-danger">Выйти</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,2 @@
|
||||
<h1>Пароль изменён</h1>
|
||||
<a href="{% url 'login' %}">Войти</a>
|
||||
6
backend/templates/registration/password_reset_email.txt
Normal file
6
backend/templates/registration/password_reset_email.txt
Normal file
@ -0,0 +1,6 @@
|
||||
Вы запросили сброс пароля на {{ site_name }}.
|
||||
|
||||
Перейдите по ссылке для смены пароля:
|
||||
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
|
||||
|
||||
Если вы не запрашивали сброс пароля, просто игнорируйте это письмо.
|
||||
7
backend/templates/registration/password_reset_form.html
Normal file
7
backend/templates/registration/password_reset_form.html
Normal file
@ -0,0 +1,7 @@
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h1>Сброс пароля</h1>
|
||||
<p>Укажите e-mail вашей учетной записи.</p>
|
||||
{{ form.email.label_tag }} {{ form.email }}
|
||||
<button type="submit">Отправить ссылку</button>
|
||||
</form>
|
||||
@ -0,0 +1 @@
|
||||
Сброс пароля на {{ site_name }}
|
||||
@ -26,22 +26,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Мессенджеры -->
|
||||
<div class="row g-3 mb-4 align-items-end">
|
||||
<div class="col-md-6 position-relative">
|
||||
<!-- Мессенджеры (добавлен WeChat) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-whatsapp text-success"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-6 position-relative">
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-telegram text-primary"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4 position-relative">
|
||||
{% bootstrap_field form.wechat show_label=False placeholder="WeChat" %}
|
||||
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
|
||||
<i class="bi bi-wechat" style="color: #07C160;"></i>
|
||||
</span>
|
||||
</div>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4">
|
||||
<div class="alert alert-danger alert-dismissible fade show mb-4" style="border-radius: 8px;">
|
||||
{% for error in form.non_field_errors %}
|
||||
<i class="bi bi-exclamation-circle me-2"></i>{{ error }}
|
||||
{% endfor %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user