## Что сделано
### Модели
- **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'.
364 lines
11 KiB
Python
364 lines
11 KiB
Python
# backend/employees/admin.py
|
|
from django.contrib import admin
|
|
from django.db.models import Count, OuterRef, Subquery
|
|
from .models import (
|
|
Employee,
|
|
EmployeeAction,
|
|
Department,
|
|
EmployeeDepartment,
|
|
EmployeePosition,
|
|
Absence,
|
|
Skill,
|
|
Education,
|
|
)
|
|
from .constants import ACTION_DISMISSED
|
|
|
|
|
|
# =========================
|
|
# Инлайны
|
|
# =========================
|
|
class EmployeeDepartmentInline(admin.TabularInline):
|
|
"""Принадлежности сотрудника к отделам (в карточке сотрудника)"""
|
|
|
|
model = EmployeeDepartment
|
|
fk_name = "employee"
|
|
extra = 0
|
|
autocomplete_fields = ("department",)
|
|
fields = ("department", "role", "is_active", "date_from", "date_to")
|
|
show_change_link = True
|
|
|
|
|
|
class EmployeeActionInline(admin.TabularInline):
|
|
model = EmployeeAction
|
|
extra = 0
|
|
fields = ("action", "date", "comment")
|
|
readonly_fields = ()
|
|
ordering = ("-date",)
|
|
|
|
|
|
class EmployeePositionInline(admin.TabularInline):
|
|
model = EmployeePosition
|
|
fk_name = "employee"
|
|
extra = 0
|
|
autocomplete_fields = ("department",)
|
|
fields = ("department", "title", "date_from", "date_to")
|
|
|
|
|
|
class AbsenceInline(admin.TabularInline):
|
|
model = Absence
|
|
fk_name = "employee"
|
|
extra = 0
|
|
fields = ("type", "date_from", "date_to", "status", "comment")
|
|
readonly_fields = ()
|
|
ordering = ("-date_from",)
|
|
|
|
|
|
class EducationInline(admin.TabularInline):
|
|
model = Education
|
|
fk_name = "employee"
|
|
extra = 0
|
|
fields = ("institution", "degree", "graduation_year")
|
|
ordering = ("-graduation_year",)
|
|
|
|
|
|
class DepartmentMembershipInline(admin.TabularInline):
|
|
"""Состав отдела (в карточке отдела)"""
|
|
|
|
model = EmployeeDepartment
|
|
fk_name = "department"
|
|
extra = 0
|
|
autocomplete_fields = ("employee",)
|
|
fields = ("employee", "role", "is_active", "date_from", "date_to")
|
|
show_change_link = True
|
|
|
|
|
|
class DepartmentPositionInline(admin.TabularInline):
|
|
"""Должности в отделе (в карточке отдела)"""
|
|
|
|
model = EmployeePosition
|
|
fk_name = "department"
|
|
extra = 0
|
|
autocomplete_fields = ("employee",)
|
|
fields = ("employee", "title", "date_from", "date_to")
|
|
|
|
|
|
# =========================
|
|
# Фильтры
|
|
# =========================
|
|
class ActuallyActiveFilter(admin.SimpleListFilter):
|
|
title = "Фактически активен"
|
|
parameter_name = "actually_active"
|
|
|
|
def lookups(self, request, model_admin):
|
|
return (
|
|
("yes", "Да"),
|
|
("no", "Нет"),
|
|
)
|
|
|
|
def queryset(self, request, queryset):
|
|
# Берём последнюю кадровую операцию и фильтруем по её типу
|
|
latest_action_subq = (
|
|
EmployeeAction.objects.filter(employee=OuterRef("pk"))
|
|
.order_by("-date")
|
|
.values("action")[:1]
|
|
)
|
|
queryset = queryset.annotate(latest_action=Subquery(latest_action_subq))
|
|
val = self.value()
|
|
if val == "yes":
|
|
return queryset.filter(latest_action__isnull=False).exclude(
|
|
latest_action=ACTION_DISMISSED
|
|
)
|
|
if val == "no":
|
|
return queryset.filter(latest_action__in=[ACTION_DISMISSED, None])
|
|
return queryset
|
|
|
|
|
|
# =========================
|
|
# Employee
|
|
# =========================
|
|
@admin.register(Employee)
|
|
class EmployeeAdmin(admin.ModelAdmin):
|
|
save_on_top = True
|
|
list_display = (
|
|
"last_name",
|
|
"first_name",
|
|
"phone_number",
|
|
"email",
|
|
"is_active",
|
|
"actually_active",
|
|
"departments_list",
|
|
"created_at",
|
|
)
|
|
list_filter = ("is_active", ActuallyActiveFilter)
|
|
search_fields = (
|
|
"last_name",
|
|
"first_name",
|
|
"patronymic",
|
|
"email",
|
|
"phone_number",
|
|
"telegram",
|
|
"wechat",
|
|
)
|
|
readonly_fields = ("created_at", "updated_at")
|
|
ordering = ("last_name", "first_name")
|
|
filter_horizontal = ("skills",)
|
|
inlines = [
|
|
EmployeeDepartmentInline,
|
|
EmployeePositionInline,
|
|
EmployeeActionInline,
|
|
AbsenceInline,
|
|
EducationInline,
|
|
]
|
|
list_select_related = ()
|
|
|
|
fieldsets = (
|
|
(
|
|
"Общая информация",
|
|
{
|
|
"fields": (
|
|
("last_name", "first_name", "patronymic"),
|
|
("gender", "birth_date"),
|
|
("avatar",),
|
|
)
|
|
},
|
|
),
|
|
(
|
|
"Контакты",
|
|
{
|
|
"fields": (
|
|
("phone_number", "email"),
|
|
("telegram", "whatsapp", "wechat"),
|
|
)
|
|
},
|
|
),
|
|
(
|
|
"Служебное",
|
|
{
|
|
"fields": (("is_active",), ("created_at", "updated_at")),
|
|
},
|
|
),
|
|
(
|
|
"Навыки",
|
|
{
|
|
"fields": ("skills",),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
)
|
|
|
|
def departments_list(self, obj):
|
|
# Корректно отобразим отделы (учитывая property obj.departments)
|
|
deps = (
|
|
obj.departments.all()
|
|
if hasattr(obj.departments, "all")
|
|
else obj.departments
|
|
)
|
|
names = [d.name for d in deps] if deps else []
|
|
return ", ".join(names) or "—"
|
|
|
|
departments_list.short_description = "Отделы"
|
|
|
|
def actually_active(self, obj):
|
|
return obj.is_actually_active
|
|
|
|
actually_active.boolean = True
|
|
actually_active.short_description = "Фактически активен"
|
|
|
|
|
|
# =========================
|
|
# EmployeeAction
|
|
# =========================
|
|
@admin.register(EmployeeAction)
|
|
class EmployeeActionAdmin(admin.ModelAdmin):
|
|
list_display = ("employee", "action", "date", "comment_short")
|
|
list_filter = ("action",)
|
|
search_fields = ("employee__last_name", "employee__first_name", "comment")
|
|
ordering = ("-date",)
|
|
date_hierarchy = "date"
|
|
|
|
def comment_short(self, obj):
|
|
return (
|
|
(obj.comment[:80] + "…")
|
|
if obj.comment and len(obj.comment) > 80
|
|
else obj.comment
|
|
)
|
|
|
|
comment_short.short_description = "Комментарий"
|
|
|
|
|
|
# =========================
|
|
# Department
|
|
# =========================
|
|
@admin.register(Department)
|
|
class DepartmentAdmin(admin.ModelAdmin):
|
|
save_on_top = True
|
|
list_display = ("name", "head", "employees_count", "created_at")
|
|
search_fields = ("name", "head__last_name", "head__first_name")
|
|
ordering = ("name",)
|
|
autocomplete_fields = ("head",)
|
|
inlines = [DepartmentMembershipInline, DepartmentPositionInline]
|
|
|
|
def get_queryset(self, request):
|
|
qs = super().get_queryset(request)
|
|
return qs.annotate(emp_cnt=Count("employeedepartment", filter=None))
|
|
|
|
def employees_count(self, obj):
|
|
return getattr(obj, "emp_cnt", 0)
|
|
|
|
employees_count.short_description = "Сотрудников"
|
|
|
|
|
|
# =========================
|
|
# EmployeeDepartment
|
|
# =========================
|
|
@admin.register(EmployeeDepartment)
|
|
class EmployeeDepartmentAdmin(admin.ModelAdmin):
|
|
list_display = (
|
|
"employee",
|
|
"department",
|
|
"role",
|
|
"is_active",
|
|
"date_from",
|
|
"date_to",
|
|
)
|
|
list_filter = ("is_active", "department")
|
|
search_fields = (
|
|
"employee__last_name",
|
|
"employee__first_name",
|
|
"department__name",
|
|
"role",
|
|
)
|
|
autocomplete_fields = ("employee", "department")
|
|
ordering = ("department", "employee")
|
|
actions = ("mark_active", "mark_inactive", "make_head")
|
|
|
|
@admin.action(description="Отметить активными")
|
|
def mark_active(self, request, queryset):
|
|
updated = queryset.update(is_active=True)
|
|
self.message_user(request, f"Обновлено записей: {updated}")
|
|
|
|
@admin.action(description="Отметить неактивными")
|
|
def mark_inactive(self, request, queryset):
|
|
updated = queryset.update(is_active=False)
|
|
self.message_user(request, f"Обновлено записей: {updated}")
|
|
|
|
@admin.action(description="Сделать выбранного сотрудника руководителем отдела")
|
|
def make_head(self, request, queryset):
|
|
# Разрешим только если выбрана ровно 1 запись
|
|
if queryset.count() != 1:
|
|
self.message_user(
|
|
request,
|
|
"Выберите ровно одну запись для назначения руководителя.",
|
|
level="warning",
|
|
)
|
|
return
|
|
link = queryset.first()
|
|
dept = link.department
|
|
dept.head = link.employee
|
|
dept.save(update_fields=["head"])
|
|
self.message_user(
|
|
request, f"{link.employee} назначен руководителем отдела «{dept.name}»."
|
|
)
|
|
|
|
|
|
# =========================
|
|
# EmployeePosition
|
|
# =========================
|
|
@admin.register(EmployeePosition)
|
|
class EmployeePositionAdmin(admin.ModelAdmin):
|
|
list_display = ("employee", "department", "title", "date_from", "date_to")
|
|
list_filter = ("department",)
|
|
search_fields = (
|
|
"employee__last_name",
|
|
"employee__first_name",
|
|
"title",
|
|
"department__name",
|
|
)
|
|
autocomplete_fields = ("employee", "department")
|
|
ordering = ("-date_from",)
|
|
|
|
|
|
# =========================
|
|
# Absence
|
|
# =========================
|
|
@admin.register(Absence)
|
|
class AbsenceAdmin(admin.ModelAdmin):
|
|
list_display = ("employee", "type", "date_from", "date_to", "status")
|
|
list_filter = ("type", "status", "date_from")
|
|
search_fields = ("employee__last_name", "employee__first_name", "comment")
|
|
autocomplete_fields = ("employee",)
|
|
ordering = ("-date_from",)
|
|
date_hierarchy = "date_from"
|
|
|
|
|
|
# =========================
|
|
# Skill
|
|
# =========================
|
|
@admin.register(Skill)
|
|
class SkillAdmin(admin.ModelAdmin):
|
|
list_display = ("name", "employees_count")
|
|
search_fields = ("name",)
|
|
ordering = ("name",)
|
|
|
|
def employees_count(self, obj):
|
|
return obj.employees.count()
|
|
|
|
employees_count.short_description = "Кол-во сотрудников"
|
|
|
|
|
|
# =========================
|
|
# Education
|
|
# =========================
|
|
@admin.register(Education)
|
|
class EducationAdmin(admin.ModelAdmin):
|
|
list_display = ("employee", "institution", "degree", "graduation_year")
|
|
list_filter = ("graduation_year",)
|
|
search_fields = (
|
|
"employee__last_name",
|
|
"employee__first_name",
|
|
"institution",
|
|
"degree",
|
|
)
|
|
autocomplete_fields = ("employee",)
|
|
ordering = ("-graduation_year",)
|