This commit is contained in:
Игорь Надеин 2025-08-05 15:30:41 +03:00
parent 6fb2cdcea0
commit a42204dda0
79 changed files with 2500 additions and 523 deletions

View File

@ -0,0 +1,53 @@
from rest_framework import permissions
from employees.models import Department
class IsDepartmentHeadOrAdmin(permissions.BasePermission):
"""
Доступ разрешён только админам или руководителю отдела.
"""
def has_permission(self, request, view):
# Для списков — только админы видят все отделы
return request.user and (
request.user.is_staff or request.user.is_superuser
)
def has_object_permission(self, request, view, obj):
# obj — это объект Department
return (
request.user and (
request.user.is_staff or
request.user.is_superuser or
(isinstance(obj, Department) and obj.head == request.user)
)
)
class IsSelfOrAdmin(permissions.BasePermission):
"""
Разрешает изменение только самому сотруднику или администратору.
"""
def has_object_permission(self, request, view, obj):
# obj — это объект Employee
return (
request.user and request.user.is_authenticated and (
request.user == obj or
request.user.is_staff or
request.user.is_superuser
)
)
class IsHR(permissions.BasePermission):
"""
Только HR (или админ/суперпользователь).
"""
def has_permission(self, request, view):
user = request.user
return (
user.is_authenticated and (
getattr(user, 'is_hr', False) or user.is_staff or user.is_superuser
)
)

View File

@ -0,0 +1,82 @@
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

19
backend/api/v1/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
EmployeeViewSet, DepartmentViewSet, EmployeeActionViewSet,
EmployeePositionViewSet, AbsenceViewSet, SkillViewSet, EducationViewSet
)
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')
urlpatterns = [
path('', include(router.urls)),
]

120
backend/api/v1/views.py Normal file
View File

@ -0,0 +1,120 @@
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from employees.models import (
Employee, Department, EmployeeAction, EmployeePosition,
Absence, Skill, Education
)
from .serializers import (
EmployeeSerializer, DepartmentSerializer, EmployeeActionSerializer,
EmployeePositionSerializer, AbsenceSerializer, SkillSerializer, EducationSerializer
)
from .permissions import IsDepartmentHeadOrAdmin, IsSelfOrAdmin, IsHR
class EmployeeViewSet(viewsets.ModelViewSet):
queryset = Employee.objects.all().prefetch_related(
'skills', 'positions', 'actions')
serializer_class = EmployeeSerializer
def get_permissions(self):
if self.action in ['update', 'partial_update', 'destroy', 'set_avatar', 'add_skill']:
return [IsSelfOrAdmin()]
return [permissions.IsAuthenticated()]
@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)
@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)
@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)
@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 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)

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-04 13:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bots', '0003_remove_botsubscriber_subscribed_at_and_more'),
]
operations = [
migrations.AddField(
model_name='botsubscriber',
name='wechat_id',
field=models.CharField(blank=True, max_length=64, null=True, unique=True, verbose_name='WeChat ID'),
),
]

View File

@ -23,10 +23,16 @@ class BotSubscriber(models.Model):
null=True, blank=True,
unique=True
)
wechat_id = models.CharField(
'WeChat ID',
max_length=64,
null=True, blank=True,
unique=True
)
class Meta:
verbose_name = 'Подписчик бота'
verbose_name_plural = 'Подписчики ботов'
def __str__(self):
return f'{self.user} (TG={self.telegram_id or "-"} / WA={self.whatsapp_id or "-"})'
return f'{self.user} (TG={self.telegram_id or "-"} / WA={self.whatsapp_id or "-"} / WC={self.wechat_id or "-"})'

View File

@ -1,15 +1,15 @@
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
import secrets
# from django.shortcuts import render, redirect
# from django.contrib.auth.decorators import login_required
# import secrets
@login_required
def generate_messenger_token(request):
user = request.user
user.registration_token = secrets.token_hex(16)
user.save(update_fields=['registration_token'])
telegram_link = f"https://t.me/your_bot?start={user.registration_token}"
# Можно также вернуть QR-код
return render(request, 'users/messenger_token.html', {
'telegram_link': telegram_link,
'token': user.registration_token,
})
# @login_required
# def generate_messenger_token(request):
# user = request.user
# user.registration_token = secrets.token_hex(16)
# user.save(update_fields=['registration_token'])
# telegram_link = f"https://t.me/your_bot?start={user.registration_token}"
# # Можно также вернуть QR-код
# return render(request, 'users/messenger_token.html', {
# 'telegram_link': telegram_link,
# 'token': user.registration_token,
# })

View File

@ -1,4 +1,4 @@
# calendar_app/models.py
# backend\calendar_app\models.py
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

View File

@ -3,7 +3,7 @@
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from .models import CompanyEvent
from users.models import Employee
from employees.models import Employee
@receiver(pre_save, sender=Employee)
def cache_old_birth_date(sender, instance: Employee, **kwargs):

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommunicationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'communications'

View File

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -5,7 +5,7 @@ from django.utils.translation import gettext_lazy as _
from django.conf import settings
from django.utils import timezone
User = settings.AUTH_USER_MODEL # 'users.Employee'
User = settings.AUTH_USER_MODEL # 'employees.Employee'
class Document(models.Model):

View File

158
backend/employees/admin.py Normal file
View File

@ -0,0 +1,158 @@
from django.contrib import admin
from .models import (
Employee, EmployeeAction, Department, EmployeePosition,
Absence, Skill, Education
)
from django.utils.html import format_html
class EmployeeActionInline(admin.TabularInline):
model = EmployeeAction
extra = 0
fields = ('action', 'date', 'comment')
readonly_fields = ('action', 'date', 'comment')
can_delete = False
show_change_link = True
class EmployeePositionInline(admin.TabularInline):
model = EmployeePosition
extra = 0
fields = ('department', 'title', 'date_from', 'date_to', 'is_active')
readonly_fields = ('is_active',)
show_change_link = True
class AbsenceInline(admin.TabularInline):
model = Absence
extra = 0
fields = ('type', 'date_from', 'date_to', 'status', 'comment')
readonly_fields = ()
show_change_link = True
class EducationInline(admin.TabularInline):
model = Education
extra = 0
fields = ('institution', 'degree', 'graduation_year')
show_change_link = True
@admin.register(Employee)
class EmployeeAdmin(admin.ModelAdmin):
list_display = (
'last_name', 'first_name', 'patronymic', 'gender', 'phone_number',
'email', 'department_list', 'current_position', 'employment_status',
'is_actually_active', 'created_at'
)
list_filter = ('gender', 'positions__department', 'actions__action')
search_fields = (
'last_name', 'first_name', 'patronymic', 'email', 'phone_number',
'telegram', 'whatsapp', 'wechat'
)
readonly_fields = ('created_at', 'updated_at',
'employment_status', 'is_actually_active', 'avatar_preview')
inlines = [EmployeeActionInline, EmployeePositionInline,
AbsenceInline, EducationInline]
filter_horizontal = ('skills',)
fieldsets = (
(None, {
'fields': (
('last_name', 'first_name', 'patronymic'),
('gender', 'birth_date'),
('avatar', 'avatar_preview'),
('phone_number', 'email'),
('telegram', 'whatsapp', 'wechat'),
('skills',),
('created_at', 'updated_at'),
)
}),
('Служебная информация', {
'fields': (
'employment_status',
'is_actually_active',
)
}),
)
def department_list(self, obj):
departments = set(
p.department.name for p in obj.positions.all() if p.is_active)
return ", ".join(departments) if departments else "—"
department_list.short_description = "Отдел(ы)"
def current_position(self, obj):
active_positions = obj.positions.filter(date_to__isnull=True)
return ", ".join(p.title for p in active_positions) if active_positions else "—"
current_position.short_description = "Должность(и)"
def avatar_preview(self, obj):
if obj.avatar:
return format_html('<img src="{}" style="height:64px;width:64px;border-radius:50%;" />', obj.avatar.url)
return "—"
avatar_preview.short_description = "Аватар"
# Отображаем статус трудоустройства через @property
def employment_status(self, obj):
return obj.employment_status
employment_status.short_description = "Статус"
def is_actually_active(self, obj):
return obj.is_actually_active
is_actually_active.boolean = True
is_actually_active.short_description = "Работает?"
@admin.register(EmployeeAction)
class EmployeeActionAdmin(admin.ModelAdmin):
list_display = ('employee', 'action', 'date', 'comment')
list_filter = ('action',)
search_fields = ('employee__last_name', 'employee__first_name', 'comment')
date_hierarchy = 'date'
@admin.register(Department)
class DepartmentAdmin(admin.ModelAdmin):
list_display = ('name', 'head', 'created_at', 'description')
search_fields = ('name',)
list_filter = ('head',)
readonly_fields = ('created_at',)
@admin.register(EmployeePosition)
class EmployeePositionAdmin(admin.ModelAdmin):
list_display = ('employee', 'department', 'title',
'date_from', 'date_to', 'is_active')
list_filter = ('department',)
search_fields = ('employee__last_name', 'employee__first_name', 'title')
readonly_fields = ('is_active',)
def is_active(self, obj):
return obj.is_active
is_active.boolean = True
is_active.short_description = "Текущая?"
@admin.register(Absence)
class AbsenceAdmin(admin.ModelAdmin):
list_display = ('employee', 'type', 'date_from',
'date_to', 'status', 'comment')
list_filter = ('type', 'status')
search_fields = ('employee__last_name', 'employee__first_name', 'comment')
@admin.register(Skill)
class SkillAdmin(admin.ModelAdmin):
list_display = ('name', 'employee_count')
search_fields = ('name',)
def employee_count(self, obj):
return obj.employees.count()
employee_count.short_description = "Кол-во сотрудников"
@admin.register(Education)
class EducationAdmin(admin.ModelAdmin):
list_display = ('employee', 'institution', 'degree', 'graduation_year')
search_fields = ('employee__last_name', 'institution', 'degree')
list_filter = ('graduation_year',)

View File

@ -0,0 +1,9 @@
# employees/apps.py
from django.apps import AppConfig
class EmployeesConfig(AppConfig):
name = 'employees'
def ready(self):
import employees.signals

View File

@ -0,0 +1,26 @@
GENDER_CHOICES = (
(1, 'Мужской'),
(2, 'Женский'),
(0, 'Не указан'),
)
ACTION_HIRED = 'hired'
ACTION_DISMISSED = 'dismissed'
ACTION_ON_LEAVE = 'on_leave'
ACTION_RETURNED_FROM_LEAVE = 'returned_from_leave'
ACTION_ON_MATERNITY = 'on_maternity'
ACTION_RETURNED_FROM_MATERNITY = 'returned_from_maternity'
ACTION_TRANSFERRED = 'transferred'
ACTION_REHIRED = 'rehired'
ACTION_CHOICES = [
(ACTION_HIRED, 'Принят'),
(ACTION_DISMISSED, 'Уволен'),
(ACTION_ON_LEAVE, 'В отпуске'),
(ACTION_RETURNED_FROM_LEAVE, 'Вернулся из отпуска'),
(ACTION_ON_MATERNITY, 'В декрете'),
(ACTION_RETURNED_FROM_MATERNITY, 'Вернулся из декрета'),
(ACTION_TRANSFERRED, 'Переведен'),
(ACTION_REHIRED, 'Восстановлен'),
]

View File

@ -76,9 +76,9 @@ class ProfileUpdateForm(AvatarValidationMixin, UserChangeForm):
'birth_date',
'gender',
'email',
'position',
'phone_number',
'telegram',
'whatsapp',
'wechat',
'avatar'
)

View File

@ -1,8 +1,10 @@
# Generated by Django 5.2.4 on 2025-07-22 06:40
# Generated by Django 5.2.4 on 2025-08-04 07:59
import django.contrib.auth.models
import django.db.models.deletion
import django.utils.timezone
import employees.models
import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models
@ -27,13 +29,17 @@ class Migration(migrations.Migration):
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('gender', models.PositiveSmallIntegerField(choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол')),
('avatar', models.ImageField(upload_to='users/avatars', verbose_name='Фото')),
('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=100, region=None, unique=True, verbose_name='Номер телефона')),
('patronymic', models.CharField(blank=True, max_length=100, verbose_name='Отчество')),
('birth_date', models.DateField(blank=True, null=True, verbose_name='Дата рождения')),
('position', models.CharField(max_length=255, verbose_name='Должность')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='email address')),
('telegram', models.CharField(blank=True, max_length=100, verbose_name='Telegram')),
('whatsapp', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None, verbose_name='WhatsApp')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
@ -43,7 +49,23 @@ class Migration(migrations.Migration):
'ordering': ('last_name', 'first_name'),
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
('objects', employees.models.EmployeeManager()),
],
),
migrations.CreateModel(
name='EmployeeAction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Кадровое событие')),
('date', models.DateTimeField(verbose_name='Дата действия')),
('comment', models.TextField(blank=True, verbose_name='Комментарий/причина')),
('extra', models.JSONField(blank=True, null=True, verbose_name='Дополнительно')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Кадровое событие',
'verbose_name_plural': 'Кадровые события',
'ordering': ['-date'],
},
),
]

View File

@ -0,0 +1,123 @@
# Generated by Django 5.2.4 on 2025-08-04 13:00
import django.db.models.deletion
import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('employees', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Skill',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
],
options={
'verbose_name': 'Навык',
'verbose_name_plural': 'Навыки',
},
),
migrations.RemoveField(
model_name='employee',
name='position',
),
migrations.AddField(
model_name='employee',
name='wechat',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None, verbose_name='WeChat'),
),
migrations.AlterField(
model_name='employee',
name='avatar',
field=models.ImageField(blank=True, null=True, upload_to='users/avatars', verbose_name='Фото'),
),
migrations.AlterField(
model_name='employee',
name='gender',
field=models.PositiveSmallIntegerField(blank=True, choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол'),
),
migrations.CreateModel(
name='Absence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('other', 'Другое')], max_length=32, verbose_name='Тип отсутствия')),
('date_from', models.DateField(verbose_name='Дата начала')),
('date_to', models.DateField(verbose_name='Дата окончания')),
('comment', models.TextField(blank=True, verbose_name='Комментарий')),
('status', models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Утверждено'), ('rejected', 'Отклонено')], default='pending', max_length=16, verbose_name='Статус')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='absences', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Отсутствие сотрудника',
'verbose_name_plural': 'Отсутствия сотрудников',
},
),
migrations.CreateModel(
name='Department',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='Название отдела')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('created_at', models.DateTimeField(auto_now_add=True)),
('head', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='headed_departments', to=settings.AUTH_USER_MODEL, verbose_name='Руководитель отдела')),
],
options={
'verbose_name': 'Отдел',
'verbose_name_plural': 'Отделы',
},
),
migrations.CreateModel(
name='Education',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('institution', models.CharField(max_length=255)),
('degree', models.CharField(max_length=100)),
('graduation_year', models.IntegerField()),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='educations', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Образование',
'verbose_name_plural': 'Образование',
},
),
migrations.CreateModel(
name='EmployeePosition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Название должности')),
('date_from', models.DateField(verbose_name='Дата начала должности')),
('date_to', models.DateField(blank=True, null=True, verbose_name='Дата окончания должности')),
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='employees.department')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='positions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Должность сотрудника',
'verbose_name_plural': 'Должности сотрудников',
},
),
migrations.AddField(
model_name='employee',
name='skills',
field=models.ManyToManyField(blank=True, related_name='employees', to='employees.skill'),
),
migrations.AddIndex(
model_name='employee',
index=models.Index(fields=['last_name', 'first_name'], name='employees_e_last_na_99a4c0_idx'),
),
migrations.AddIndex(
model_name='employee',
index=models.Index(fields=['email'], name='employees_e_email_8f5bbc_idx'),
),
migrations.AddIndex(
model_name='employee',
index=models.Index(fields=['phone_number'], name='employees_e_phone_n_b0f1e7_idx'),
),
]

View File

221
backend/employees/models.py Normal file
View File

@ -0,0 +1,221 @@
# backend\employees\models.py
from django.db import models
from django.contrib.auth.models import AbstractUser, BaseUserManager
from phonenumber_field.modelfields import PhoneNumberField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from .constants import GENDER_CHOICES, ACTION_CHOICES, ACTION_DISMISSED
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 = PhoneNumberField('WeChat', 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)
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 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
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='employees')
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})'

View File

@ -2,6 +2,7 @@
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from .constants import ACTION_HIRED
from .models import Employee, EmployeeAction
from bots.models import BotSubscriber
@ -29,6 +30,10 @@ def unbind_bot_subscriber_on_contact_change(sender, instance: Employee, **kwargs
sub.whatsapp_id = None
sub.save(update_fields=['whatsapp_id'])
if old.wechat and old.wechat != instance.wechat:
sub.wechat_id = None
sub.save(update_fields=['wechat_id'])
@receiver(post_save, sender=Employee)
def create_hired_action(sender, instance: Employee, created, **kwargs):
@ -38,7 +43,7 @@ def create_hired_action(sender, instance: Employee, created, **kwargs):
if created:
EmployeeAction.objects.create(
employee=instance,
action=EmployeeAction.ACTION_HIRED,
action=ACTION_HIRED,
date=instance.created_at or timezone.now(),
comment='Автоматически: принят при регистрации'
)

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,14 @@
from django.urls import path
from . import views_front
app_name = 'employees'
urlpatterns = [
path('profile/', views_front.profile, name='profile'),
path('profile/avatar_remove/', views_front.avatar_remove, name='avatar_remove'),
path('employees/<int:pk>/', views_front.employee_detail, name='employee_detail'),
path('departments/', views_front.department_list, name='department_list'),
path('departments/<int:pk>/', views_front.department_detail,
name='department_detail'),
path('', views_front.employees_list, name='employees_list'),
]

View File

@ -0,0 +1,86 @@
# backend\employees\views_front.py
from feed.models import Post
import os
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib import messages
from django.urls import reverse
from django.conf import settings
from .forms import ProfileUpdateForm
from .models import Employee, Department
from bots.models import BotSubscriber
from dotenv import load_dotenv
load_dotenv()
USERNAME_TELEGRAM_BOT = os.getenv('USERNAME_TELEGRAM_BOT')
@login_required
def profile(request):
"""Страница своего профиля с лентой публикаций и личной инфой"""
employee = request.user
posts = Post.objects.filter(author=employee).order_by('-created_at')[:10]
form = ProfileUpdateForm(instance=employee)
return render(request, 'employees/profile.html', {
'form': form,
'employee': employee,
'own': True,
'posts': posts,
})
@login_required
def employee_detail(request, pk):
"""Страница чужого профиля с инфо и лентой публикаций"""
employee = get_object_or_404(Employee, pk=pk)
if employee == request.user:
return redirect('employees:profile')
posts = Post.objects.filter(author=employee).order_by('-created_at')[:10]
return render(request, 'employees/profile.html', {
'employee': employee,
'own': False,
'posts': posts,
})
@login_required
def avatar_remove(request):
"""Удаление аватарки пользователя"""
if request.method == 'POST':
user = request.user
if user.avatar:
user.avatar.delete(save=True)
messages.success(request, 'Аватар успешно удалён.')
return redirect('employees:profile')
return redirect('employees:profile')
@login_required
def employees_list(request):
"""Список всех сотрудников (виден только авторизованным)"""
employees = Employee.objects.select_related().order_by('last_name', 'first_name')
return render(request, 'employees/employees_list.html', {
'employees': employees,
})
@login_required
def department_list(request):
"""Список отделов"""
departments = Department.objects.all().order_by('name')
return render(request, 'employees/department_list.html', {
'departments': departments,
})
@login_required
def department_detail(request, pk):
"""Детальная страница отдела"""
department = get_object_or_404(Department, pk=pk)
employees = department.employees.all().select_related('employee')
return render(request, 'employees/department_detail.html', {
'department': department,
'employees': employees,
})

View File

@ -22,11 +22,13 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'users.apps.UsersConfig',
'employees.apps.EmployeesConfig',
'api.apps.ApiConfig',
'hikcentral.apps.HikcentralConfig',
'calendar_app.apps.CalendarAppConfig',
'documents.apps.DocumentsConfig',
'requests_app.apps.RequestsAppConfig',
'feed.apps.FeedConfig',
'bots',
]
@ -94,7 +96,7 @@ else:
'USER': os.getenv('POSTGRES_USER', 'django'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD', ''),
'HOST': os.getenv('DB_HOST', ''),
'PORT': os.getenv('DB_PORT', 5432)
'PORT': os.getenv('DB_PORT', '5432')
}
AUTH_PASSWORD_VALIDATORS = [
@ -127,10 +129,10 @@ STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
AUTH_USER_MODEL = 'users.Employee'
AUTH_USER_MODEL = 'employees.Employee'
LOGIN_URL = 'register'
LOGIN_REDIRECT_URL = 'profile'
LOGIN_REDIRECT_URL = 'login'
LOGOUT_REDIRECT_URL = 'login'
@ -182,3 +184,4 @@ LOGGING = {
},
}
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS', default='https://*.sytes.net').split(',')

View File

@ -1,8 +1,8 @@
from django.contrib import admin
from django.urls import include, path, reverse_lazy
from django.views.generic.edit import CreateView
from users.forms import RegistrationForm
from users.views import profile, avatar_remove
from employees.forms import RegistrationForm
# from backend.employees.views_front import profile, avatar_remove
from django.conf.urls.static import static
from django.conf import settings
@ -15,8 +15,10 @@ urlpatterns = [
form_class=RegistrationForm,
success_url=reverse_lazy('profile')
), name='register'),
path('avatar/remove/', avatar_remove, name='avatar_remove'),
# path('avatar/remove/', avatar_remove, name='avatar_remove'),
path('calendar/', include('calendar_app.urls', namespace='calendar')),
path('documents/', include('documents.urls', namespace='documents')),
path('', profile, name='profile'),
path('requests/', include('requests_app.urls', namespace='requests_app')),
path('employees/', include('employees.urls_front', namespace='employees')),
path('', include('feed.urls', namespace='feed')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

0
backend/feed/__init__.py Normal file
View File

13
backend/feed/admin.py Normal file
View File

@ -0,0 +1,13 @@
from django.contrib import admin
from .models import Post, Comment
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'type', 'author', 'department', 'created_at', 'pinned']
list_filter = ['type', 'department', 'pinned']
search_fields = ['title', 'body', 'author__last_name']
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ['author', 'post', 'created_at']
search_fields = ['text', 'author__last_name', 'post__title']

6
backend/feed/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FeedConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'feed'

25
backend/feed/forms.py Normal file
View File

@ -0,0 +1,25 @@
from django import forms
from .models import Post, Comment
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['type', 'department', 'title', 'body', 'image', 'attachment']
widgets = {
'type': forms.Select(attrs={'class': 'form-select'}),
'department': forms.Select(attrs={'class': 'form-select'}),
'title': forms.TextInput(attrs={'class': 'form-control'}),
'body': forms.Textarea(attrs={'class': 'form-control', 'rows': 5}),
'image': forms.ClearableFileInput(attrs={'class': 'form-control'}),
'attachment': forms.ClearableFileInput(attrs={'class': 'form-control'}),
}
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['text']
widgets = {
'text': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}

View File

@ -0,0 +1,48 @@
# Generated by Django 5.2.4 on 2025-08-05 07:27
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('employees', '0002_skill_remove_employee_position_employee_wechat_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('body', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('pinned', models.BooleanField(default=False)),
('type', models.CharField(choices=[('company', 'Новость компании'), ('department', 'Новость отдела'), ('personal', 'Публикация сотрудника')], max_length=20)),
('attachment', models.FileField(blank=True, null=True, upload_to='feed/attachments/')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)),
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to='employees.department')),
],
options={
'ordering': ['-pinned', '-created_at'],
},
),
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='feed.post')),
],
options={
'ordering': ['created_at'],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-05 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('feed', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='post',
name='image',
field=models.ImageField(blank=True, null=True, upload_to='feed/images/'),
),
]

View File

48
backend/feed/models.py Normal file
View File

@ -0,0 +1,48 @@
# backend\feed\models.py
from django.db import models
from employees.models import Department
from django.contrib.auth import get_user_model
Employee = get_user_model()
class Post(models.Model):
TYPE_CHOICES = [
('company', 'Новость компании'),
('department', 'Новость отдела'),
('personal', 'Публикация сотрудника'),
]
author = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name='posts')
department = models.ForeignKey(
Department, null=True, blank=True, on_delete=models.CASCADE, related_name='posts')
title = models.CharField(max_length=200)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
pinned = models.BooleanField(default=False)
type = models.CharField(max_length=20, choices=TYPE_CHOICES)
image = models.ImageField(upload_to='feed/images/', blank=True, null=True)
attachment = models.FileField(
upload_to='feed/attachments/', blank=True, null=True)
class Meta:
ordering = ['-pinned', '-created_at']
def __str__(self):
return self.title
class Comment(models.Model):
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name='comments')
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'Комментарий от {self.author} к "{self.post}"'

3
backend/feed/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

14
backend/feed/urls.py Normal file
View File

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'feed'
urlpatterns = [
path('', views.feed_list, name='feed_list'),
path('departments/<int:pk>/', views.department_feed, name='department_feed'),
path('employees/<int:pk>/', views.employee_feed, name='employee_feed'),
path('post/<int:pk>/', views.post_detail, name='post_detail'),
path('post/new/', views.post_create, name='post_create'),
path('post/<int:pk>/pin/', views.pin_post, name='pin_post'),
]

126
backend/feed/views.py Normal file
View File

@ -0,0 +1,126 @@
# backend\feed\views.py
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .models import Post, Comment
from .forms import PostForm, CommentForm
from employees.models import Department, Employee
def feed_list(request):
posts = Post.objects.filter(type='company').order_by(
'-pinned', '-created_at')
active_employees = sorted(
[emp for emp in Employee.objects.all() if emp.is_actually_active],
key=lambda e: e.created_at,
reverse=True
)
new_count = max(1, int(len(active_employees) * 0.1))
new_employees = Employee.get_active()[:new_count]
return render(request, 'feed/feed_list.html', {
'posts': posts,
'new_employees': new_employees,
})
@login_required
def department_feed(request, pk):
department = get_object_or_404(Department, pk=pk)
posts = Post.objects.filter(type='department', department=department).order_by(
'-pinned', '-created_at')
new_employees = department.employees.filter(
employee__actions__action__in=['hired', 'rehired', 'transferred']
).distinct().order_by('-employee__created_at')[:5]
return render(request, 'feed/department_feed.html', {
'department': department,
'posts': posts,
'new_employees': new_employees,
})
@login_required
def employee_feed(request, pk):
"""Публикации конкретного сотрудника"""
employee = get_object_or_404(Employee, pk=pk)
posts = Post.objects.filter(author=employee).order_by('-created_at')
return render(request, 'feed/employee_feed.html', {'employee': employee, 'posts': posts})
@login_required
def post_detail(request, pk):
"""Детальная страница публикации с комментариями"""
post = get_object_or_404(Post, pk=pk)
comments = post.comments.select_related('author')
if request.method == 'POST':
comment_form = CommentForm(request.POST)
if comment_form.is_valid():
comment = comment_form.save(commit=False)
comment.author = request.user
comment.post = post
comment.save()
messages.success(request, "Комментарий добавлен!")
return redirect('feed:post_detail', pk=pk)
else:
comment_form = CommentForm()
return render(request, 'feed/post_detail.html', {
'post': post,
'comments': comments,
'comment_form': comment_form
})
@login_required
def post_create(request):
"""Создание новой публикации"""
if request.method == 'POST':
form = PostForm(request.POST, request.FILES)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user
if post.type == 'department' and not post.department:
messages.error(request, "Для новостей отдела выберите отдел.")
return render(request, 'feed/post_form.html', {'form': form})
post.save()
messages.success(request, "Публикация создана!")
# Перенаправление по типу публикации
if post.type == 'company':
return redirect('feed:feed_list')
elif post.type == 'department':
return redirect('feed:department_feed', pk=post.department.pk)
else:
return redirect('feed:employee_feed', pk=request.user.pk)
else:
form = PostForm()
return render(request, 'feed/post_form.html', {'form': form})
def is_department_head(user, department):
return user.is_staff or (department.head == user)
@login_required
def pin_post(request, pk):
post = get_object_or_404(Post, pk=pk)
# Главные новости: только staff
if post.type == 'company' and not request.user.is_staff:
messages.error(
request, "Только администратор может закреплять новости компании.")
return redirect('feed:post_detail', pk=pk)
# Новости отдела: только начальник отдела
if post.type == 'department' and not is_department_head(request.user, post.department):
messages.error(
request, "Только руководитель отдела может закреплять новости отдела.")
return redirect('feed:post_detail', pk=pk)
post.pinned = not post.pinned
post.save()
action = "Закреплено" if post.pinned else "Откреплено"
messages.success(request, f"Новость {action}!")
# редирект по типу поста
if post.type == 'company':
return redirect('feed:feed_list')
elif post.type == 'department':
return redirect('feed:department_feed', pk=post.department.pk)
else:
return redirect('feed:employee_feed', pk=post.author.pk)

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class RequestsAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'requests_app'

View File

@ -0,0 +1,42 @@
from django import forms
from .models import Request
class RequestForm(forms.ModelForm):
class Meta:
model = Request
fields = ['type', 'date_from', 'date_to', 'comment']
widgets = {
'type': forms.Select(attrs={'class': 'form-select'}),
'date_from': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'date_to': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
labels = {
'type': 'Тип заявления',
'date_from': 'Дата начала',
'date_to': 'Дата окончания',
'comment': 'Комментарий',
}
help_texts = {
'type': 'Выберите тип заявления',
'date_from': 'Если не требуется — оставьте пустым',
'date_to': 'Если не требуется — оставьте пустым',
}
class RequestStatusForm(forms.ModelForm):
class Meta:
model = Request
fields = ['status', 'comment']
widgets = {
'status': forms.Select(attrs={'class': 'form-select'}),
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
labels = {
'status': 'Статус заявления',
'comment': 'Комментарий (для сотрудника)',
}
help_texts = {
'status': 'Измените статус, чтобы согласовать или отклонить заявление.',
}

View File

@ -0,0 +1,35 @@
# Generated by Django 5.2.4 on 2025-08-05 06:17
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Request',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления')),
('date_from', models.DateField(blank=True, null=True, verbose_name='Дата начала')),
('date_to', models.DateField(blank=True, null=True, verbose_name='Дата окончания')),
('comment', models.TextField(blank=True, verbose_name='Комментарий')),
('status', models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], default='pending', max_length=16, verbose_name='Статус')),
('created_at', models.DateTimeField(auto_now_add=True)),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Заявление',
'verbose_name_plural': 'Заявления',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,37 @@
from django.db import models
from django.contrib.auth import get_user_model
Employee = get_user_model()
class Request(models.Model):
TYPE_CHOICES = [
('vacation', 'Отпуск'),
('sick_leave', 'Больничный'),
('transfer', 'Перевод'),
('dismissal', 'Увольнение'),
('other', 'Другое'),
]
STATUS_CHOICES = [
('pending', 'На рассмотрении'),
('approved', 'Одобрено'),
('rejected', 'Отклонено'),
]
employee = models.ForeignKey(
Employee, on_delete=models.CASCADE, related_name='requests')
type = models.CharField('Тип заявления', choices=TYPE_CHOICES, max_length=32)
date_from = models.DateField('Дата начала', null=True, blank=True)
date_to = models.DateField('Дата окончания', null=True, blank=True)
comment = models.TextField('Комментарий', blank=True)
status = models.CharField(
'Статус', choices=STATUS_CHOICES, max_length=16, default='pending')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Заявление'
verbose_name_plural = 'Заявления'
ordering = ['-created_at']
def __str__(self):
return f"{self.get_type_display()} — {self.employee} ({self.get_status_display()})"

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'requests_app'
urlpatterns = [
path('my/', views.my_requests, name='my_requests'),
path('new/', views.request_create, name='request_create'),
path('my/<int:pk>/', views.request_detail, name='request_detail'),
path('all/', views.all_requests, name='all_requests'),
path('process/<int:pk>/', views.request_process, name='request_process'),
path('delete/<int:pk>/', views.request_delete, name='request_delete'),
]

View File

@ -0,0 +1,90 @@
from django.contrib.auth.decorators import login_required
from .models import Request
from .forms import RequestStatusForm, RequestForm
from django.contrib.auth.decorators import user_passes_test
from django.shortcuts import render
from django.contrib.auth.decorators import login_required, user_passes_test
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
@login_required
def request_create(request):
"""Создать новое заявление"""
if request.method == 'POST':
form = RequestForm(request.POST)
if form.is_valid():
req = form.save(commit=False)
req.employee = request.user
req.save()
messages.success(request, "Заявление отправлено!")
return redirect('requests_app:my_requests')
else:
form = RequestForm()
return render(request, 'requests_app/request_form.html', {'form': form})
@login_required
def request_detail(request, pk):
req = get_object_or_404(Request, pk=pk, employee=request.user)
return render(request, 'requests_app/request_detail.html', {'request_obj': req})
def is_hr_or_head(user):
return user.is_staff or user.groups.filter(name__in=['HR', 'Heads']).exists()
@user_passes_test(is_hr_or_head)
def all_requests(request):
"""Список всех заявлений (для HR/руководителя)"""
# Для HR — все заявки, для руководителя — только по его отделу
if request.user.is_staff or request.user.groups.filter(name='HR').exists():
requests_qs = Request.objects.all().order_by('-created_at')
else:
# Показываем заявки сотрудников его отдела
requests_qs = Request.objects.filter(
employee__positions__department__head=request.user).distinct().order_by('-created_at')
return render(request, 'requests_app/all_requests.html', {'requests': requests_qs})
@user_passes_test(is_hr_or_head)
def request_process(request, pk):
req = get_object_or_404(Request, pk=pk)
if request.method == 'POST':
form = RequestStatusForm(request.POST, instance=req)
if form.is_valid():
form.save()
messages.success(request, "Статус заявления обновлён.")
return redirect('requests_app:all_requests')
else:
form = RequestStatusForm(instance=req)
return render(request, 'requests_app/request_process.html', {
'request_obj': req,
'form': form
})
@login_required
def my_requests(request):
"""Список всех заявлений текущего пользователя"""
requests_qs = Request.objects.filter(
employee=request.user).order_by('-created_at')
return render(request, 'requests_app/my_requests.html', {
'requests': requests_qs,
})
@login_required
def request_delete(request, pk):
"""Удаление (отзыв) собственного заявления, если оно ещё не рассмотрено"""
req = get_object_or_404(Request, pk=pk, employee=request.user)
if req.status != 'pending':
messages.error(
request, "Нельзя отозвать заявление, уже рассмотренное HR или руководителем.")
return redirect('requests_app:my_requests')
if request.method == "POST":
req.delete()
messages.success(request, "Заявление успешно отозвано и удалено.")
return redirect('requests_app:my_requests')
return render(request, 'requests_app/request_confirm_delete.html', {'request_obj': req})

View File

@ -1,3 +1,5 @@
{% load static django_bootstrap5 %}
<!DOCTYPE html>
<html lang="ru">
<head>
@ -5,17 +7,123 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}EUSRR{% endblock %}</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- FullCalendar CSS -->
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
{% bootstrap_css %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet">
{% block extra_css %}{% endblock %}
<style>
nav.navbar.position-relative { position: relative; }
:root {
--main-bg: #f8fafc;
--main-text: #222;
--sidebar-bg: #f8fafc;
--sidebar-text: #34405a;
--sidebar-border: #e5eaf3;
--sidebar-active-bg: #e8ecf2;
--sidebar-active-text: #12306a;
--sidebar-icon: #8090b1;
--sidebar-active-icon: #12306a;
--navbar-bg: #296299;
--navbar-text: #fff;
--navbar-border: #204c77;
}
@media (prefers-color-scheme: dark) {
:root {
--main-bg: #161b22;
--main-text: #e6e6e6;
--sidebar-bg: #161b22;
--sidebar-text: #e6e6e6;
--sidebar-border: #222c3c;
--sidebar-active-bg: #232c3c;
--sidebar-active-text: #7ac7ff;
--sidebar-icon: #8fa5c1;
--sidebar-active-icon: #7ac7ff;
--navbar-bg: #22335a;
--navbar-text: #fff;
--navbar-border: #1b263b;
}
}
body {
min-height: 100vh;
background: var(--main-bg);
color: var(--main-text);
}
.app-navbar {
height: 60px;
background: var(--navbar-bg);
color: var(--navbar-text);
border-bottom: 1px solid var(--navbar-border);
z-index: 1020;
}
.sidebar {
width: 220px;
min-height: 100vh;
background: var(--sidebar-bg);
color: var(--sidebar-text);
position: fixed;
top: 0; left: 0;
border-right: 1px solid var(--sidebar-border);
z-index: 1010;
padding-top: 60px;
}
.sidebar .nav-link {
color: var(--sidebar-text);
font-weight: 500;
padding: 10px 20px;
border-radius: 6px;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 10px;
transition: background 0.12s, color 0.12s;
}
.sidebar .nav-link.active,
.sidebar .nav-link:hover {
background: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: 600;
}
.sidebar .nav-link.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.sidebar .nav-link i {
font-size: 1.2rem;
color: var(--sidebar-icon);
}
.sidebar .nav-link.active i,
.sidebar .nav-link:hover i {
color: var(--sidebar-active-icon);
}
.main-content {
margin-left: 220px;
padding: 30px 24px 24px 24px;
min-height: 100vh;
padding-top: 90px;
background: var(--main-bg);
color: var(--main-text);
transition: margin-left .2s;
}
@media (max-width: 991.98px) {
.sidebar {
left: -230px;
transition: left .2s;
padding-top: 60px;
}
.sidebar.show {
left: 0;
}
.main-content {
margin-left: 0;
padding-top: 90px;
}
.app-navbar {
left: 0;
width: 100vw;
}
}
/* Calendar styles */
#calendarContainer {
position: absolute;
top: -9999px;
@ -23,84 +131,170 @@
z-index: 1050;
width: 350px; max-width: 100%; height: 400px; max-height: 90vh;
overflow: hidden;
transition: top 0.2s ease-in-out;
transition: top 0.2s;
}
#calendarContainer.visible { top: 100%; }
#mini-calendar { width: 100%; height: 100%; }
.fc .fc-toolbar-title { font-size: 1.2rem; font-weight: 500; }
.fc .fc-daygrid-day-number { font-size: 1rem; font-weight: 400; }
.fc .fc-daygrid-day-frame { padding: 0.5rem; }
.fc .fc-toolbar-title { font-size: 1.1rem; font-weight: 500; }
.fc .fc-daygrid-day-number { font-size: 1rem; }
.fc .fc-daygrid-day-frame { padding: 0.4rem; }
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary position-relative">
<div class="container">
<a class="navbar-brand" href="/">EUSRR</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarContent" aria-controls="navbarContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarContent">
<ul class="navbar-nav me-auto">
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'documents:document_list' %}">
<i class="bi-file-earmark-text"></i> Документы
</a>
</li>
{% endif %}
</ul>
<body>
{% if user.is_authenticated %}
<nav class="navbar app-navbar navbar-dark position-fixed w-100 shadow-sm">
<div class="container-fluid d-flex justify-content-between align-items-center px-3">
<div class="d-flex align-items-center">
{% if user.is_authenticated %}
<span class="navbar-text me-3">Добро пожаловать, {{ user.get_short_name }}</span>
<button class="btn btn-outline-light d-lg-none me-2" type="button" id="sidebarToggle">
<i class="bi-list"></i>
</button>
<a href="/" class="navbar-brand mb-0 text-white fs-5 d-none d-lg-inline" style="letter-spacing:1px;">EUSRR</a>
</div>
<div class="d-flex align-items-center gap-3">
<!-- Кнопка календаря -->
<button id="toggleCalendarBtn" class="btn btn-outline-light" type="button" title="Календарь">
<i class="bi-calendar3"></i>
</button>
<!-- Кнопка переключения календаря -->
<button id="toggleCalendarBtn" class="btn btn-outline-light me-3" type="button">
<i class="bi-calendar3"></i>
<!-- Виджет уведомлений -->
<div class="dropdown">
<button class="btn btn-outline-light position-relative" data-bs-toggle="dropdown" aria-expanded="false" id="notificationDropdown" title="Уведомления">
<i class="bi-bell"></i>
{% if not user.telegram or not user.whatsapp or not user.wechat %}
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">!</span>
{% endif %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow" style="min-width: 260px;">
<li class="small text-muted px-3 py-2">Мессенджеры</li>
<li class="px-3">
{% if user.telegram %}
<span class="text-success"><i class="bi-telegram"></i> Telegram привязан</span>
{% else %}
<a href="{{ tg_link|default:'#' }}" class="text-danger d-block"><i class="bi-telegram"></i> Привязать Telegram</a>
{% endif %}
</li>
<li class="px-3">
{% if user.whatsapp %}
<span class="text-success"><i class="bi-whatsapp"></i> WhatsApp привязан</span>
{% else %}
<a href="https://wa.me/?text=Привязка+WhatsApp" class="text-danger d-block"><i class="bi-whatsapp"></i> Привязать WhatsApp</a>
{% endif %}
</li>
<li class="px-3">
{% if user.wechat %}
<span class="text-success"><i class="bi-wechat"></i> WeChat привязан</span>
{% else %}
<span class="text-danger d-block"><i class="bi-wechat"></i> Привязать WeChat (вручную)</span>
{% endif %}
</li>
<li><hr class="dropdown-divider"></li>
<li class="small text-muted px-3 py-2">Уведомления (скоро)</li>
</ul>
</div>
<!-- /Виджет уведомлений -->
<!-- Контейнер календаря -->
<div id="calendarContainer">
<div class="card shadow-sm">
<div class="card-body p-2">
<div id="mini-calendar"></div>
</div>
</div>
</div>
<form action="{% url 'logout' %}" method="post" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-outline-light">Выйти</button>
</form>
{% else %}
<a class="btn btn-outline-light me-2" href="{% url 'login' %}">Войти</a>
<a class="btn btn-light" href="{% url 'register' %}">Регистрация</a>
{% endif %}
<!-- Дропдаун пользователя -->
<div class="dropdown">
<a href="#" class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
id="userDropdown" data-bs-toggle="dropdown" aria-expanded="false" style="min-width:40px;">
{% if user.avatar %}
<img src="{{ user.avatar.url }}"
alt="Аватар" width="36" height="36"
class="rounded-circle shadow-sm" style="object-fit: cover;">
{% else %}
<span class="avatar-placeholder bg-secondary text-white rounded-circle d-flex align-items-center justify-content-center"
style="width:36px;height:36px;font-size:1.2rem;">
{{ user.get_short_name|slice:":1"|upper }}
</span>
{% endif %}
</a>
<ul class="dropdown-menu dropdown-menu-end shadow" aria-labelledby="userDropdown">
<li class="px-3 py-2 text-muted small">
{{ user.get_short_name }}<br>
<span class="text-secondary">{{ user.email }}</span>
</li>
<li><hr class="dropdown-divider"></li>
<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>
</li>
</ul>
</div>
</div>
</div>
</div>
</nav>
</nav>
<div class="container mt-5">
<div class="sidebar d-flex flex-column">
<div class="mb-4 text-center d-lg-none pt-3">
<a href="/" class="navbar-brand text-dark fs-4 py-2 d-block mb-2" style="letter-spacing:1px;">EUSRR</a>
</div>
<ul class="nav flex-column">
<li><a href="{% url 'feed:feed_list' %}" class="nav-link {% if request.resolver_match.url_name == 'feed_list' %}active{% endif %}">
<i class="bi-megaphone"></i> Новости
</a></li>
<li><a href="{% url 'employees:employees_list' %}" class="nav-link {% if request.resolver_match.url_name == 'employees_list' %}active{% endif %}">
<i class="bi-people"></i> Сотрудники
</a></li>
<li><a href="{% url 'employees:department_list' %}" class="nav-link {% if request.resolver_match.url_name == 'department_list' %}active{% endif %}">
<i class="bi-diagram-3"></i> Отделы
</a></li>
<li><a href="{% url 'requests_app:my_requests' %}" class="nav-link {% if request.resolver_match.url_name == 'my_requests' %}active{% endif %}">
<i class="bi-journal-text"></i> Мои заявления
</a></li>
<li><a href="#" class="nav-link disabled">
<i class="bi-chat-dots"></i> Чат
</a></li>
<li><a href="#" class="nav-link disabled">
<i class="bi-calendar-event"></i> Календарь
</a></li>
<li><a href="{% url 'documents:document_list' %}" class="nav-link {% if request.resolver_match.url_name == 'document_list' %}active{% endif %}">
<i class="bi-file-earmark-text"></i> Документы
</a></li>
</ul>
</div>
{% else %}
<nav class="navbar app-navbar navbar-dark position-fixed w-100 shadow-sm">
<div class="container-fluid d-flex justify-content-between align-items-center px-3">
<span class="navbar-brand">EUSRR</span>
<div>
<a class="btn btn-outline-light me-2" href="{% url 'login' %}">Войти</a>
<a class="btn btn-light" href="{% url 'register' %}">Регистрация</a>
</div>
</div>
</nav>
{% endif %}
<div class="main-content">
{% block content %}{% endblock %}
</div>
<!-- Bootstrap JS Bundle -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<!-- FullCalendar JS -->
{% bootstrap_javascript %}
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js"></script>
{% if user.is_authenticated %}
<script>
// Sidebar toggle (for mobile)
document.addEventListener('DOMContentLoaded', function() {
const sidebar = document.querySelector('.sidebar');
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', function() {
sidebar.classList.toggle('show');
});
}
// Calendar logic
const container = document.getElementById('calendarContainer');
const toggleBtn = document.getElementById('toggleCalendarBtn');
// Инициализация FullCalendar
const calendar = new FullCalendar.Calendar(
document.getElementById('mini-calendar'),
{
@ -116,16 +310,12 @@
}
);
calendar.render();
// Показ/скрытие календаря
toggleBtn.addEventListener('click', () => {
container.classList.toggle('visible');
if (container.classList.contains('visible')) {
calendar.updateSize();
}
});
// Закрытие по клику вне области
document.addEventListener('click', (e) => {
if (container.classList.contains('visible')
&& !container.contains(e.target)
@ -138,6 +328,5 @@
{% endif %}
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Заявление на отсутствие{% endblock %}
{% block content %}
<h2>Заявление на отсутствие</h2>
<form method="post" enctype="multipart/form-data" class="card p-4 shadow-sm">
{% csrf_token %}
{% bootstrap_form form layout='horizontal' %}
<button type="submit" class="btn btn-primary">Отправить заявление</button>
</form>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}{{ department.name }}{% endblock %}
{% block content %}
<h2>{{ department.name }}</h2>
<p class="text-muted">{{ department.description }}</p>
<p><strong>Руководитель:</strong> {{ department.head }}</p>
<h5 class="mt-4">Сотрудники отдела:</h5>
<ul class="list-group">
{% for position in employees %}
<li class="list-group-item">
<a href="{% url 'employees:employee_detail' position.employee.pk %}">
{{ position.employee.last_name }} {{ position.employee.first_name }}
</a>
<span class="ms-2 text-secondary">{{ position.title }}</span>
</li>
{% empty %}
<li class="list-group-item">Нет сотрудников в этом отделе.</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Отделы{% endblock %}
{% block content %}
<h2 class="mb-4">Отделы</h2>
<ul class="list-group">
{% for dept in departments %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<a href="{% url 'employees:department_detail' dept.pk %}">{{ dept.name }}</a>
{% if dept.head %}<span class="badge bg-secondary">{{ dept.head }}</span>{% endif %}
</li>
{% empty %}
<li class="list-group-item">Нет отделов.</li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Сотрудники{% endblock %}
{% block content %}
<h2 class="mb-4">Список сотрудников</h2>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for emp in employees %}
<div class="col">
<div class="card h-100 shadow-sm">
<div class="card-body d-flex align-items-center">
{% if emp.avatar %}
<img src="{{ emp.avatar.url }}" class="rounded-circle me-3" style="width:64px;height:64px;object-fit:cover;">
{% else %}
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3" style="width:64px;height:64px;">
<i class="bi-person fs-2 text-white"></i>
</div>
{% endif %}
<div>
<h5 class="card-title mb-0">
<a href="{% url 'employees:employee_detail' emp.pk %}" class="text-decoration-none">
{{ emp.last_name }} {{ emp.first_name }}
</a>
</h5>
<div class="text-muted small">{{ emp.email }}</div>
{% if emp.positions.first %}
<div class="text-secondary">{{ emp.positions.first.title }}</div>
{% endif %}
</div>
</div>
</div>
</div>
{% empty %}
<div class="col">
<div class="alert alert-warning">Нет сотрудников.</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@ -0,0 +1,98 @@
{% extends "base.html" %}
{% load static django_bootstrap5 %}
{% block title %}{% if own %}Мой профиль{% else %}Профиль сотрудника{% endif %}{% endblock %}
{% block content %}
<div class="container">
<div class="row g-5">
<!-- Блок с аватаркой и личной инфой -->
<div class="col-md-4">
<div class="card shadow">
<div class="card-body text-center">
{% if employee.avatar %}
<img src="{{ employee.avatar.url }}" alt="Аватар {{ employee.get_full_name }}" class="img-fluid rounded-circle mb-3" style="width: 140px; height: 140px; object-fit: cover;">
{% else %}
<div class="avatar-placeholder bg-secondary text-white rounded-circle d-flex align-items-center justify-content-center mb-3"
style="width:140px; height:140px; font-size:3rem;">
{{ employee.get_short_name|slice:":1"|upper }}
</div>
{% endif %}
<h4>{{ employee.last_name }} {{ employee.first_name }}{% if employee.patronymic %} {{ employee.patronymic }}{% endif %}</h4>
<p class="mb-1">
<span class="badge bg-primary">{{ employee.get_current_position }}</span>
<br>
<span class="text-muted">{{ employee.department }}</span>
</p>
{% if employee.skills.all %}
<div class="mb-2">
<i class="bi-award me-1"></i> Навыки:
{% for skill in employee.skills.all %}
<span class="badge bg-light text-dark border">{{ skill.name }}</span>
{% endfor %}
</div>
{% endif %}
<p class="small mb-1 text-muted">
{{ employee.email }}<br>
{{ employee.phone_number }}
</p>
{% if employee.telegram %}<a href="https://t.me/{{ employee.telegram }}" target="_blank"><i class="bi-telegram"></i> Telegram</a>{% endif %}
{% if employee.whatsapp %}<br><a href="https://wa.me/{{ employee.whatsapp }}" target="_blank"><i class="bi-whatsapp"></i> WhatsApp</a>{% endif %}
{% if employee.wechat %}<br><span><i class="bi-wechat"></i> WeChat: {{ employee.wechat }}</span>{% endif %}
{% if own %}
<div class="mt-3">
<a href="#" class="btn btn-outline-secondary" onclick="document.getElementById('editProfileForm').style.display='block';return false;">Редактировать профиль</a>
<form method="post" action="{% url 'employees:avatar_remove' %}" class="d-inline">
{% csrf_token %}
{% if employee.avatar %}
<button type="submit" class="btn btn-link text-danger">Удалить аватар</button>
{% endif %}
</form>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Блок с лентой и формой (или только лентой) -->
<div class="col-md-8">
{% if own %}
<div id="editProfileForm" style="display:none;">
<h5>Редактирование профиля</h5>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn btn-link" onclick="document.getElementById('editProfileForm').style.display='none';">Отмена</button>
</form>
<hr>
</div>
{% endif %}
<h5 class="mb-3">Публикации</h5>
{% if posts %}
{% for post in posts %}
<div class="card mb-3">
<div class="card-body">
<a href="{% url 'feed:post_detail' pk=post.pk %}" class="fw-semibold">{{ post.title }}</a>
<div class="text-muted small mb-1">
{{ post.created_at|date:"d.m.Y H:i" }}
{% if post.pinned %} <i class="bi-pin-angle-fill text-warning" title="Закреплено"></i>{% endif %}
</div>
<div>
{{ post.body|truncatechars:160|linebreaksbr }}
</div>
{% if post.image %}
<img src="{{ post.image.url }}" class="img-fluid mt-2 rounded shadow-sm" style="max-width: 180px;">
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">Публикаций пока нет.</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}{{ department.name }} — Новости отдела{% endblock %}
{% block content %}
<h2 class="mb-4">Новости отдела: {{ department.name }}</h2>
<a href="{% url 'feed:post_create' %}" class="btn btn-primary mb-4">
<i class="bi-plus-circle"></i> Новая публикация
</a>
{% if new_employees %}
<div class="alert alert-light p-2 mb-3">
<div class="fw-bold mb-1">Новые сотрудники отдела:</div>
<div class="d-flex gap-2">
{% for emp in new_employees %}
<a href="{% url 'employees:employee_detail' emp.pk %}" title="{{ emp.get_full_name }}">
{% if emp.avatar %}
<img src="{{ emp.avatar.url }}" class="rounded-circle border" style="width: 40px; height: 40px; object-fit: cover;">
{% else %}
<span class="bi-person-circle fs-2 text-secondary"></span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if posts %}
<div class="row g-4">
{% for post in posts %}
<div class="col-12">
<div class="card shadow-sm">
{% if post.image %}
<img src="{{ post.image.url }}" class="card-img-top"
alt="{{ post.title }}"
style="max-height: 240px; object-fit: cover; border-top-left-radius:.5rem; border-top-right-radius:.5rem;">
{% endif %}
<div class="card-body">
<h5 class="card-title mb-2 d-flex align-items-center">
{{ post.title }}
{% if post.pinned %}
<span class="badge bg-warning text-dark ms-1">Закреплено</span>
{% endif %}
{# Кнопка закрепить — только для начальника отдела #}
{% if department.head == user %}
<a href="{% url 'feed:pin_post' post.pk %}" class="btn btn-sm btn-outline-warning ms-2">
{% if post.pinned %} Открепить {% else %} Закрепить {% endif %}
</a>
{% endif %}
</h5>
<p class="card-text mb-2">{{ post.body|truncatewords:32 }}</p>
<div class="small text-muted mb-1">
Автор: {{ post.author.get_full_name|default:post.author.username }} • {{ post.created_at|date:"d.m.Y H:i" }}
</div>
<a href="{% url 'feed:post_detail' post.pk %}" class="btn btn-sm btn-outline-primary">
Подробнее
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">В отделе пока нет новостей.</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Публикации: {{ employee.get_full_name }}{% endblock %}
{% block content %}
<h2 class="mb-4">Публикации: {{ employee.get_full_name }}</h2>
{% if posts %}
<div class="row g-4">
{% for post in posts %}
<div class="col-12">
<div class="card shadow-sm">
{% if post.image %}
<img src="{{ post.image.url }}" class="card-img-top"
alt="{{ post.title }}"
style="max-height: 240px; object-fit: cover; border-top-left-radius:.5rem; border-top-right-radius:.5rem;">
{% endif %}
<div class="card-body">
<h5 class="card-title mb-2">
{{ post.title }}
{% if post.pinned %}
<span class="badge bg-warning text-dark ms-1">Закреплено</span>
{% endif %}
</h5>
<p class="card-text mb-2">{{ post.body|truncatewords:32 }}</p>
<div class="small text-muted mb-1">
Автор: {{ post.author.get_full_name|default:post.author.username }} • {{ post.created_at|date:"d.m.Y H:i" }}
</div>
<a href="{% url 'feed:post_detail' post.pk %}" class="btn btn-sm btn-outline-primary">
Подробнее
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">У сотрудника пока нет публикаций.</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Новости компании{% endblock %}
{% block content %}
<h2 class="mb-4">Новости компании</h2>
{% if user.is_staff %}
<a href="{% url 'feed:post_create' %}" class="btn btn-primary mb-4">
<i class="bi-plus-circle"></i> Новая публикация
</a>
{% endif %}
{% if new_employees %}
<div class="mb-4 bg-white p-3 rounded shadow-sm">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0 text-secondary">Присоединились недавно</h6>
<a href="{% url 'employees:employees_list' %}" class="small text-primary">Все сотрудники</a>
</div>
<div class="d-flex flex-wrap gap-3">
{% for emp in new_employees %}
<div class="text-center" style="width: 90px;">
<a href="{% url 'employees:employee_detail' emp.pk %}" class="text-decoration-none text-dark">
{% if emp.avatar %}
<img src="{{ emp.avatar.url }}" class="rounded-circle border mb-1" style="width: 56px; height: 56px; object-fit: cover;">
{% else %}
<span class="bi-person-circle fs-1 text-secondary"></span>
{% endif %}
<div class="small">{{ emp.get_short_name }}</div>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if posts %}
<div class="row g-4">
{% for post in posts %}
<div class="col-12">
<div class="card shadow-sm">
{% if post.image %}
<img src="{{ post.image.url }}"
alt="{{ post.title }}"
class="card-img-top"
style="max-height: 260px; object-fit: cover; border-top-left-radius:.5rem; border-top-right-radius:.5rem;">
{% endif %}
<div class="card-body">
<h5 class="card-title mb-2 d-flex align-items-center">
{% if post.type == 'company' and user.is_staff %}
<a href="{% url 'feed:pin_post' post.pk %}" class="btn btn-sm btn-outline-warning ms-2">
{% if post.pinned %} Открепить {% else %} Закрепить {% endif %}
</a>
{% elif post.type == 'department' and department and department.head == user %}
<a href="{% url 'feed:pin_post' post.pk %}" class="btn btn-sm btn-outline-warning ms-2">
{% if post.pinned %} Открепить {% else %} Закрепить {% endif %}
</a>
{% endif %}
</h5>
<p class="card-text mb-2">{{ post.body|truncatewords:32 }}</p>
<div class="small text-muted mb-1">
Автор: {{ post.author.get_full_name|default:post.author.username }} • {{ post.created_at|date:"d.m.Y H:i" }}
</div>
<a href="{% url 'feed:post_detail' post.pk %}" class="btn btn-sm btn-outline-primary">
Подробнее
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">Пока нет новостей.</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,65 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
<div class="mb-4">
<a href="{% url 'feed:feed_list' %}" class="btn btn-outline-primary">
<i class="bi-arrow-left"></i> К ленте
</a>
</div>
<div class="card shadow-sm mb-4">
{% if post.image %}
<img src="{{ post.image.url }}" class="card-img-top" alt="{{ post.title }}">
{% endif %}
<div class="card-body">
<h4 class="card-title mb-2">
{{ post.title }}
{% if post.pinned %}
<span class="badge bg-warning text-dark ms-1">Закреплено</span>
{% endif %}
</h4>
<div class="mb-2 small text-muted">
Автор: {{ post.author.get_full_name|default:post.author.username }} • {{ post.created_at|date:"d.m.Y H:i" }}
</div>
<div class="mb-2">
{{ post.body|linebreaks }}
</div>
{% if post.attachment %}
<a href="{{ post.attachment.url }}" target="_blank" class="btn btn-sm btn-outline-info">
<i class="bi-paperclip"></i> Вложение
</a>
{% endif %}
</div>
</div>
<!-- Комментарии -->
<h5 class="mb-3">Комментарии</h5>
<div class="mb-4">
{% for comment in comments %}
<div class="card mb-2">
<div class="card-body p-3">
<div class="small mb-2 text-muted">
{{ comment.author.get_full_name|default:comment.author.username }},
{{ comment.created_at|date:"d.m.Y H:i" }}
</div>
{{ comment.text|linebreaks }}
</div>
</div>
{% empty %}
<div class="alert alert-info">Пока нет комментариев.</div>
{% endfor %}
</div>
<!-- Форма добавления комментария -->
<div class="card p-3 shadow-sm">
<h6 class="mb-3">Оставить комментарий</h6>
<form method="post">
{% csrf_token %}
{% bootstrap_form comment_form %}
<button type="submit" class="btn btn-primary mt-2">Добавить комментарий</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}{{ form.instance.pk|yesno:"Редактировать,Создать" }} публикацию{% endblock %}
{% block content %}
<h2 class="mb-4">
{{ form.instance.pk|yesno:"Редактировать,Создать" }} публикацию
</h2>
<form method="post" enctype="multipart/form-data" class="mb-4">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success">Сохранить</button>
<a href="javascript:history.back()" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View File

@ -1,99 +0,0 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block content %}
<div class="container py-5">
<div class="row g-5">
<!-- Блок с аватаром -->
<div class="col-md-4 text-center">
<div class="card shadow">
<div class="card-body">
{% if user.avatar and user.avatar.url %}
<img src="{{ user.avatar.url }}"
alt="Аватар {{ user.first_name }} {{ user.last_name }}"
class="img-fluid rounded-circle mb-3"
style="width: 200px; height: 200px; object-fit: cover;">
{% else %}
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center"
style="width: 200px; height: 200px; margin: 0 auto;">
<i class="bi bi-person-circle fs-1 text-muted"></i>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Форма редактирования -->
<div class="col-md-8">
<div class="card shadow">
<div class="card-body">
<h2 class="card-title mb-4">Редактирование профиля</h2>
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
<div class="row g-3">
{% bootstrap_form form layout='horizontal' %}
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-2"></i>Сохранить изменения
</button>
<a href="{% url 'profile' %}" class="btn btn-outline-secondary">
Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Блок привязки Telegram -->
{% comment %}
Показываем, только если в контексте есть bot_sub и Telegram ещё не привязан
{% endcomment %}
{% if bot_sub and not bot_sub.telegram_id %}
<div class="row">
<div class="col-12">
<div class="alert alert-info mt-4">
<b>Для привязки Telegram:</b>
<a href="{{ tg_link }}" target="_blank" class="btn btn-success ms-2">
Привязать Telegram
</a>
</div>
</div>
</div>
{% endif %}
{% if bot_sub and not bot_sub.whatsapp_id %}
<div class="row">
<div class="col-12">
<div class="alert alert-info mt-4">
<b>Для привязки WhatsApp:</b>
<a href="{{ tg_link }}" target="_blank" class="btn btn-success ms-2">
Привязать WhatsApp
</a>
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
// Предпросмотр аватара
const avatarInput = document.getElementById('avatarInput');
if (avatarInput) {
avatarInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const preview = document.querySelector('.rounded-circle');
if (preview.tagName === 'IMG') {
preview.src = URL.createObjectURL(file);
preview.onload = () => URL.revokeObjectURL(preview.src);
}
}
});
}
</script>
{% endblock %}

View File

@ -0,0 +1,55 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Все заявления сотрудников{% endblock %}
{% block content %}
<h2 class="mb-4">Заявления сотрудников</h2>
{% if requests %}
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th>Сотрудник</th>
<th>Тип</th>
<th>Период</th>
<th>Статус</th>
<th>Дата подачи</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for req in requests %}
<tr>
<td>{{ req.employee.get_full_name }}</td>
<td>{{ req.get_type_display }}</td>
<td>
{% if req.date_from or req.date_to %}
{{ req.date_from|date:'d.m.Y' }}{% if req.date_to %} — {{ req.date_to|date:'d.m.Y' }}{% endif %}
{% else %}
—
{% endif %}
</td>
<td>
{% if req.status == 'pending' %}
<span class="badge bg-secondary">На рассмотрении</span>
{% elif req.status == 'approved' %}
<span class="badge bg-success">Одобрено</span>
{% elif req.status == 'rejected' %}
<span class="badge bg-danger">Отклонено</span>
{% endif %}
</td>
<td>{{ req.created_at|date:'d.m.Y H:i' }}</td>
<td>
<a href="{% url 'requests_app:request_process' req.pk %}" class="btn btn-sm btn-outline-primary">
Рассмотреть
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">Заявлений нет.</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Мои заявления{% endblock %}
{% block content %}
<h2 class="mb-4">Мои заявления</h2>
<a href="{% url 'requests_app:request_create' %}" class="btn btn-primary mb-4">
<i class="bi-plus-circle"></i> Подать новое заявление
</a>
{% if requests %}
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th>Тип</th>
<th>Период</th>
<th>Статус</th>
<th>Комментарий</th>
<th>Дата подачи</th>
<th></th>
</tr>
</thead>
<tbody>
{% for req in requests %}
<tr>
<td>{{ req.get_type_display }}</td>
<td>
{% if req.date_from or req.date_to %}
{{ req.date_from|date:'d.m.Y' }}{% if req.date_to %} — {{ req.date_to|date:'d.m.Y' }}{% endif %}
{% else %}
—
{% endif %}
</td>
<td>
{% if req.status == 'pending' %}
<span class="badge bg-secondary">На рассмотрении</span>
{% elif req.status == 'approved' %}
<span class="badge bg-success">Одобрено</span>
{% elif req.status == 'rejected' %}
<span class="badge bg-danger">Отклонено</span>
{% endif %}
</td>
<td>
{{ req.comment|default:'—' }}
</td>
<td>{{ req.created_at|date:'d.m.Y H:i' }}</td>
<td>
<a href="{% url 'requests_app:request_detail' req.pk %}" class="btn btn-sm btn-outline-primary">
Подробнее
</a>
{% if req.status == 'pending' %}
<a href="{% url 'requests_app:request_delete' req.pk %}" class="btn btn-sm btn-outline-danger ms-1">
Отозвать
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">У вас пока нет заявлений.</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Отозвать заявление{% endblock %}
{% block content %}
<h2 class="mb-4 text-danger">Отозвать заявление</h2>
<div class="alert alert-warning">
<strong>Внимание!</strong> После отзыва заявление будет удалено без возможности восстановления.
</div>
<form method="post">
{% csrf_token %}
<dl>
<dt>Тип:</dt><dd>{{ request_obj.get_type_display }}</dd>
<dt>Период:</dt><dd>
{% if request_obj.date_from or request_obj.date_to %}
{{ request_obj.date_from|date:'d.m.Y' }}{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{% else %}—{% endif %}
</dd>
<dt>Комментарий:</dt><dd>{{ request_obj.comment|default:"—" }}</dd>
</dl>
<button type="submit" class="btn btn-danger">
<i class="bi-trash"></i> Отозвать и удалить
</button>
<a href="{% url 'requests_app:my_requests' %}" class="btn btn-outline-secondary ms-2">Отмена</a>
</form>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Заявление{% endblock %}
{% block content %}
<h2 class="mb-4">Детали заявления</h2>
<div class="card shadow-sm">
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Тип:</dt>
<dd class="col-sm-9">{{ request_obj.get_type_display }}</dd>
<dt class="col-sm-3">Период:</dt>
<dd class="col-sm-9">
{% if request_obj.date_from or request_obj.date_to %}
{{ request_obj.date_from|date:'d.m.Y' }}{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{% else %}
—
{% endif %}
</dd>
<dt class="col-sm-3">Статус:</dt>
<dd class="col-sm-9">
{% if request_obj.status == 'pending' %}
<span class="badge bg-secondary">На рассмотрении</span>
{% elif request_obj.status == 'approved' %}
<span class="badge bg-success">Одобрено</span>
{% elif request_obj.status == 'rejected' %}
<span class="badge bg-danger">Отклонено</span>
{% endif %}
</dd>
<dt class="col-sm-3">Комментарий:</dt>
<dd class="col-sm-9">{{ request_obj.comment|default:"—" }}</dd>
<dt class="col-sm-3">Дата подачи:</dt>
<dd class="col-sm-9">{{ request_obj.created_at|date:'d.m.Y H:i' }}</dd>
</dl>
<a href="{% url 'requests_app:my_requests' %}" class="btn btn-outline-secondary">К списку заявлений</a>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Подать заявление{% endblock %}
{% block content %}
<h2 class="mb-4">Подать новое заявление</h2>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success mt-3">
<i class="bi-check-circle"></i> Отправить заявление
</button>
<a href="{% url 'requests_app:my_requests' %}" class="btn btn-outline-secondary mt-3 ms-2">
Отмена
</a>
</form>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Рассмотрение заявления{% endblock %}
{% block content %}
<h2 class="mb-4">Рассмотрение заявления</h2>
<div class="mb-4">
<strong>Сотрудник:</strong> {{ request_obj.employee.get_full_name }}<br>
<strong>Тип:</strong> {{ request_obj.get_type_display }}<br>
<strong>Период:</strong>
{% if request_obj.date_from or request_obj.date_to %}
{{ request_obj.date_from|date:'d.m.Y' }}{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{% else %}
—
{% endif %}
<br>
<strong>Комментарий сотрудника:</strong> {{ request_obj.comment|default:"—" }}<br>
<strong>Дата подачи:</strong> {{ request_obj.created_at|date:'d.m.Y H:i' }}
</div>
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success">
<i class="bi-check-circle"></i> Сохранить решение
</button>
<a href="{% url 'requests_app:all_requests' %}" class="btn btn-outline-secondary ms-2">
Отмена
</a>
</form>
{% endblock %}

View File

@ -1,43 +0,0 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Employee, EmployeeAction
@admin.register(Employee)
class EmployeeAdmin(admin.ModelAdmin):
list_display = (
'last_name', 'first_name', 'phone_number', 'position', 'employment_status', 'is_actually_active'
)
list_filter = ('position',)
search_fields = ('last_name', 'first_name', 'phone_number', 'email')
readonly_fields = ('created_at', 'updated_at')
ordering = ('last_name', 'first_name')
fieldsets = (
(None, {
'fields': ('first_name', 'last_name', 'patronymic', 'gender', 'avatar', 'birth_date')
}),
(_('Contact Info'), {
'fields': ('phone_number', 'email', 'telegram', 'whatsapp')
}),
(_('Job'), {
'fields': ('position',)
}),
(_('Timestamps'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',),
}),
)
@admin.register(EmployeeAction)
class EmployeeActionAdmin(admin.ModelAdmin):
list_display = ('employee', 'action', 'date', 'comment')
list_filter = ('action', 'date')
search_fields = ('employee__last_name', 'employee__first_name', 'comment')
date_hierarchy = 'date'
ordering = ('-date',)
def save_model(self, request, obj, form, change):
# При создании или обновлении записи автоматически обновляем статус сотрудника
super().save_model(request, obj, form, change)

View File

@ -1,9 +0,0 @@
# users/apps.py
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'
def ready(self):
import users.signals # noqa

View File

@ -1,5 +0,0 @@
GENDER_CHOICES = (
(1, 'Мужской'),
(2, 'Женский'),
(0, 'Не указан'),
)

View File

@ -1,18 +0,0 @@
# Generated by Django 5.2.4 on 2025-07-24 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='employee',
name='gender',
field=models.PositiveSmallIntegerField(choices=[(1, 'Мужской'), (2, 'Женский'), (0, 'Не указан')], default=0, verbose_name='Пол'),
),
]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.2.4 on 2025-07-25 06:35
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0002_employee_gender'),
]
operations = [
migrations.AddField(
model_name='employee',
name='birth_date',
field=models.DateField(blank=True, null=True, verbose_name='Дата рождения'),
),
migrations.AddField(
model_name='employee',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='employee',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.CreateModel(
name='EmployeeAction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('hired', 'Принят'), ('dismissed', 'Уволен'), ('on_leave', 'В отпуске'), ('returned_from_leave', 'Вернулся из отпуска'), ('on_maternity', 'В декрете'), ('returned_from_maternity', 'Вернулся из декрета'), ('transferred', 'Переведен'), ('rehired', 'Восстановлен')], max_length=32, verbose_name='Кадровое событие')),
('date', models.DateTimeField(verbose_name='Дата действия')),
('comment', models.TextField(blank=True, verbose_name='Комментарий/причина')),
('extra', models.JSONField(blank=True, null=True, verbose_name='Дополнительно')),
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Кадровое событие',
'verbose_name_plural': 'Кадровые события',
'ordering': ['-date'],
},
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 5.2.4 on 2025-07-29 05:31
import users.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0003_employee_birth_date_employee_created_at_and_more'),
]
operations = [
migrations.AlterModelManagers(
name='employee',
managers=[
('objects', users.models.EmployeeManager()),
],
),
]

View File

@ -1,142 +0,0 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.base_user import BaseUserManager
from phonenumber_field.modelfields import PhoneNumberField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from .constants import GENDER_CHOICES
class EmployeeManager(BaseUserManager):
use_in_migrations = True
def create_user(self, phone_number, email, password=None, **extra_fields):
"""
Создает и сохраняет обычного сотрудника с переданным номером телефона и 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.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 extra_fields.get('is_staff') is not True:
raise ValueError(_('Суперпользователь должен иметь is_staff=True'))
if extra_fields.get('is_superuser') is not True:
raise ValueError(
_('Суперпользователь должен иметь is_superuser=True'))
return self.create_user(phone_number, email, password, **extra_fields)
class Employee(AbstractUser):
gender = models.PositiveSmallIntegerField(
_('Пол'), choices=GENDER_CHOICES, default=0)
avatar = models.ImageField(_('Фото'), upload_to='users/avatars')
phone_number = PhoneNumberField(
'Номер телефона', max_length=100, unique=True, blank=False)
patronymic = models.CharField(_('Отчество'), max_length=100, blank=True)
birth_date = models.DateField(_('Дата рождения'), blank=True, null=True)
position = models.CharField(_('Должность'), max_length=255)
email = models.EmailField(_('email address'), unique=True, blank=False)
telegram = models.CharField(
'Telegram', max_length=100, blank=True)
whatsapp = PhoneNumberField('WhatsApp', blank=True)
username = None
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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')
def clean(self):
super().clean()
if not self.whatsapp and not self.telegram:
raise ValidationError(
'Заполните хотя бы одно из полей: WhatsApp или Telegram',
code='contact_required'
)
def __str__(self):
return f'{self.last_name} {self.first_name}'
@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 is_actually_active(self):
"""Сотрудник считается работающим по последнему действию"""
last_action = self.actions.order_by('-date').first()
if not last_action:
return False
return last_action.action in [
EmployeeAction.ACTION_HIRED,
EmployeeAction.ACTION_REHIRED,
EmployeeAction.ACTION_RETURNED_FROM_LEAVE,
EmployeeAction.ACTION_RETURNED_FROM_MATERNITY,
EmployeeAction.ACTION_TRANSFERRED,
]
class EmployeeAction(models.Model):
ACTION_HIRED = 'hired'
ACTION_DISMISSED = 'dismissed'
ACTION_ON_LEAVE = 'on_leave'
ACTION_RETURNED_FROM_LEAVE = 'returned_from_leave'
ACTION_ON_MATERNITY = 'on_maternity'
ACTION_RETURNED_FROM_MATERNITY = 'returned_from_maternity'
ACTION_TRANSFERRED = 'transferred'
ACTION_REHIRED = 'rehired'
ACTION_CHOICES = [
(ACTION_HIRED, 'Принят'),
(ACTION_DISMISSED, 'Уволен'),
(ACTION_ON_LEAVE, 'В отпуске'),
(ACTION_RETURNED_FROM_LEAVE, 'Вернулся из отпуска'),
(ACTION_ON_MATERNITY, 'В декрете'),
(ACTION_RETURNED_FROM_MATERNITY, 'Вернулся из декрета'),
(ACTION_TRANSFERRED, 'Переведен'),
(ACTION_REHIRED, 'Восстановлен'),
]
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}'

View File

@ -1,41 +0,0 @@
import os
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import ProfileUpdateForm
from bots.models import BotSubscriber
from dotenv import load_dotenv
load_dotenv()
USERNAME_TELEGRAM_BOT = os.getenv('USERNAME_TELEGRAM_BOT')
@login_required
def profile(request):
bot_sub, _ = BotSubscriber.objects.get_or_create(user=request.user)
if request.method == 'POST':
form = ProfileUpdateForm(request.POST, request.FILES, instance=request.user)
if form.is_valid():
form.save()
return redirect('profile')
else:
form = ProfileUpdateForm(instance=request.user)
tg_link = f"https://t.me/{USERNAME_TELEGRAM_BOT}"
return render(request, 'profile.html', {
'form': form,
'tg_link': tg_link,
'bot_sub': bot_sub,
})
@login_required
def avatar_remove(request):
if request.method == 'POST':
user = request.user
if user.avatar:
user.avatar.delete(save=True)
messages.success(request, 'Аватар успешно удалён')
return redirect('profile')
return redirect('profile')