Corp/backend/employees/models.py
Игорь Надеин 2c85086f78 feat(feed,employees,communications): dept news, invites, chat sync; forms/admin cleanup; templates polish
## Что сделано

### Модели
- **employees**
  - : теперь включает отделы, где сотрудник является **руководителем**.
  - Уточнены :
    -  →  у ,  у .
  - : добавлены удобные геттеры , ; улучшён .
  - Модели , ,  приведены к актуальным полям (без start_date/end_date/level и т.п., чтобы не конфликтовать с формами/админкой).

- **feed**
  - Вынесены константы типов постов: , ,  + .
  - Жёсткие **CheckConstraint**:
    - для департаментских постов ,
    - для остальных — .
  - Индексы по , , .

- **communications**
  - :
    -  для  (чаты отдела удаляются вместе с отделом).
    - Метод : активные сотрудники отдела + **руководитель**; для глобального — все активные.
    - Запрет удаления **основных** чатов (global/department), но обычные удаляются нормально.

### Админка
- Приведена в соответствие моделям: убраны несуществующие поля, добавлены фильтры/поиск по реальным полям, inlines для отделов (связи сотрудников).
- Исправлены ошибки system check (E033/E108/E116).

### Формы
- , : валидация аватара, корректные required/optional, PhoneNumberField для WhatsApp/WeChat.
- ,  (динамический queryset сотрудников, не состоящих в отделе).
- , , : соответствуют актуальным моделям, с базовой валидацией диапазонов дат (если применимо).

### Вьюхи (employees)
- Переписаны аккуратно и проще:
  - Списки/детали отделов на CBV (, ), в контексте — сотрудники, новости отдела, новые сотрудники.
  - Профиль и чужой профиль использует общий шаблон с подключаемыми блоками.
  - : доступ только **админу/руководителю**; добавление связи  и автодобавление участника (и руководителя) в **чат отдела**.
  - : подготовлено к фильтрации по .

### Вьюхи (feed)
-  — **универсальная** форма:
  - контекст определяется автоматически из GET:  | ,
  - скрываем поля , чтобы пользователь не ломал контекст,
  - редирект — на **красивые** страницы: главная лента / страница отдела / лента сотрудника.
- : только админ — для компании, только **руководитель отдела** — для департамента.
- : комментарии + форма добавления.
- **Добавлены** CRUD для постов и комментариев:
  - ,  — автор/админ/руководитель отдела.
  - ,  — только автор комментария.

### URL-ы
- : добавлены маршруты , , , .
- : все нужные эндпоинты в рабочем состоянии,  на месте.

### Шаблоны
- : опрятная шапка, линк на «Все сотрудники отдела», кнопка «Пригласить» (для админа/руководителя), внизу — include **новостей отдела**.
- : приведение к общему стилю, кнопка «Новая публикация» с автоподстановкой department.
- : отображает бейдж контекста (Компания/Отдел/Личное), **одна** кнопка «Отмена»,  приходит из вьюхи.
- : контекстная кнопка «Назад к ленте» (определяется автоматически), кнопки **Редактировать/Удалить** (по правам), комментарии с кнопками **ред./удал.** у автора.
- Исправлены include-пути (везде , без обратных слешей).

## Почему всё ок
- Нет циклических импортов,  выровнены.
- Никакой магии в шаблонах: вся логика определения контекста — во вьюхах.
- Чаты отдела удаляются вместе с отделом; основные чаты защищены от удаления.
- Учитываем **руководителя отдела** везде, где нужно (новости, чат, права).

## Что проверить локально
1) Миграции:
   - python manage.py makemigrations
   - python manage.py migrate
2) Создать отдел, назначить руководителя.
3) Пригласить сотрудника → автоматически попадёт в чат отдела.
4) Создать новости:
   - с главной → тип company,
   - со страницы отдела → тип department, поле Department скрыто,
   - из ленты сотрудника → тип employee.
5) Закрепление новостей:
   - компания — только staff,
   - отдел — только head.
6) Редактирование/удаление постов и комментариев — по правам.

## Примечания
- Если ранее в БД были посты с типом 'personal' — вручную мигрируйте на 'employee'.
2025-08-08 15:16:19 +03:00

301 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend\employees\models.py
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
# from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from .constants import ACTION_CHOICES, ACTION_DISMISSED, GENDER_CHOICES
class EmployeeManager(BaseUserManager):
use_in_migrations = True
def create_user(self, phone_number, email, password=None, **extra_fields):
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.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, phone_number, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
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)
class Employee(AbstractUser):
# Отключаем username
username = None
gender = models.PositiveSmallIntegerField(
"Пол", choices=GENDER_CHOICES, default=0, blank=True
)
avatar = models.ImageField("Фото", upload_to="users/avatars", blank=True, null=True)
phone_number = PhoneNumberField("Номер телефона", max_length=100, unique=True)
patronymic = models.CharField("Отчество", max_length=100, blank=True)
birth_date = models.DateField("Дата рождения", blank=True, null=True)
email = models.EmailField("email address", unique=True)
telegram = models.CharField("Telegram", max_length=100, blank=True)
whatsapp = PhoneNumberField("WhatsApp", blank=True)
wechat = models.CharField("WeChat", max_length=100, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
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)
objects = EmployeeManager()
USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = ["first_name", "last_name", "email"]
class Meta:
verbose_name = "Сотрудник"
verbose_name_plural = "Сотрудники"
ordering = ("last_name", "first_name")
indexes = [
models.Index(fields=["last_name", "first_name"]),
models.Index(fields=["email"]),
models.Index(fields=["phone_number"]),
]
def clean(self):
super().clean()
if not (self.whatsapp or self.telegram or self.wechat):
raise ValidationError(
"Заполните хотя бы одно из полей: WhatsApp, WeChat или Telegram",
code="contact_required",
)
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()
if last_action:
return last_action.get_action_display()
return "Нет данных"
@property
def departments(self):
from employees.models import Department, EmployeeDepartment
# Отделы, где сотрудник активен
department_ids = EmployeeDepartment.objects.filter(
employee=self, is_active=True
).values_list("department_id", flat=True)
# Плюс отделы, где сотрудник начальник
head_ids = Department.objects.filter(head=self).values_list("id", flat=True)
return Department.objects.filter(
models.Q(id__in=department_ids) | models.Q(id__in=head_ids)
).distinct()
@property
def is_actually_active(self):
last_action = self.actions.order_by("-date").first()
if not last_action:
return False
return last_action.action != ACTION_DISMISSED
class EmployeeAction(models.Model):
employee = models.ForeignKey(
Employee, related_name="actions", on_delete=models.CASCADE
)
action = models.CharField("Кадровое событие", max_length=32, choices=ACTION_CHOICES)
date = models.DateTimeField("Дата действия")
comment = models.TextField("Комментарий/причина", blank=True)
extra = models.JSONField("Дополнительно", blank=True, null=True)
class Meta:
verbose_name = "Кадровое событие"
verbose_name_plural = "Кадровые события"
ordering = ["-date"]
def __str__(self):
return f"{self.employee} — {self.get_action_display()} — {self.date:%d.%m.%Y}"
class Department(models.Model):
name = models.CharField("Название отдела", max_length=255, unique=True)
description = models.TextField("Описание", blank=True)
head = models.ForeignKey(
"Employee",
on_delete=models.SET_NULL,
verbose_name="Руководитель отдела",
related_name="headed_departments",
null=True,
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Отдел"
verbose_name_plural = "Отделы"
def __str__(self):
return self.name
@property
def active_employees(self):
"""
Все активные сотрудники отдела (через EmployeeDepartment),
включая руководителя, если он есть.
"""
qs = self.employeedepartment.filter(is_active=True).select_related("employee")
employees = [ed.employee for ed in qs]
if self.head and self.head not in employees:
employees.append(self.head)
return employees
@property
def new_employees(self):
"""
Новые сотрудники отдела за последний месяц (по дате присоединения).
"""
month_ago = timezone.now() - timezone.timedelta(days=30)
qs = self.employeedepartment.filter(
is_active=True, date_from__gte=month_ago
).select_related("employee")
employees = [ed.employee for ed in qs]
# Добавляем руководителя, если он недавно назначен
if self.head:
head_joined = self.created_at >= month_ago
if head_joined and self.head not in employees:
employees.append(self.head)
return employees
class EmployeeDepartment(models.Model):
employee = models.ForeignKey(
"Employee", on_delete=models.CASCADE, related_name="departments_links"
)
department = models.ForeignKey(
Department, on_delete=models.CASCADE, related_name="employeedepartment"
)
role = models.CharField("Роль в отделе", max_length=100, blank=True)
date_from = models.DateField("Дата начала работы", null=True, blank=True)
date_to = models.DateField("Дата окончания работы", null=True, blank=True)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ("employee", "department", "date_from")
verbose_name = "Принадлежность к отделу"
verbose_name_plural = "Принадлежности к отделам"
def __str__(self):
return f"{self.employee} в {self.department} ({self.role or 'сотрудник'})"
class EmployeePosition(models.Model):
"""
Опциональная модель для хранения названия должности.
Не влияет на определение, в каком отделе сотрудник работает.
"""
employee = models.ForeignKey(
"Employee", on_delete=models.CASCADE, related_name="positions"
)
department = models.ForeignKey(
Department, on_delete=models.CASCADE, related_name="positions"
)
title = models.CharField("Название должности", max_length=255)
date_from = models.DateField("Дата начала должности")
date_to = models.DateField("Дата окончания должности", null=True, blank=True)
class Meta:
verbose_name = "Должность сотрудника"
verbose_name_plural = "Должности сотрудников"
@property
def is_active(self):
return not self.date_to or self.date_to >= timezone.now().date()
def __str__(self):
return f"{self.employee} — {self.title} ({self.department})"
class Absence(models.Model):
ABSENCE_TYPE_CHOICES = [
("vacation", "Отпуск"),
("sick_leave", "Больничный"),
("other", "Другое"),
]
STATUS_CHOICES = [
("pending", "На рассмотрении"),
("approved", "Утверждено"),
("rejected", "Отклонено"),
]
employee = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name="absences"
)
type = models.CharField(
"Тип отсутствия", choices=ABSENCE_TYPE_CHOICES, max_length=32
)
date_from = models.DateField("Дата начала")
date_to = models.DateField("Дата окончания")
comment = models.TextField("Комментарий", blank=True)
status = models.CharField(
"Статус", choices=STATUS_CHOICES, max_length=16, default="pending"
)
class Meta:
verbose_name = "Отсутствие сотрудника"
verbose_name_plural = "Отсутствия сотрудников"
def __str__(self):
return f"{self.employee} — {self.get_type_display()} ({self.date_from}–{self.date_to})"
class Skill(models.Model):
name = models.CharField(max_length=100, unique=True)
class Meta:
verbose_name = "Навык"
verbose_name_plural = "Навыки"
def __str__(self):
return self.name
class Education(models.Model):
employee = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name="educations"
)
institution = models.CharField(max_length=255)
degree = models.CharField(max_length=100)
graduation_year = models.IntegerField()
class Meta:
verbose_name = "Образование"
verbose_name_plural = "Образование"
def __str__(self):
return f"{self.institution} ({self.degree}, {self.graduation_year})"