0
This commit is contained in:
parent
6fb2cdcea0
commit
a42204dda0
53
backend/api/v1/permissions.py
Normal file
53
backend/api/v1/permissions.py
Normal 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
|
||||
)
|
||||
)
|
||||
82
backend/api/v1/serializers.py
Normal file
82
backend/api/v1/serializers.py
Normal 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
19
backend/api/v1/urls.py
Normal 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
120
backend/api/v1/views.py
Normal 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)
|
||||
18
backend/bots/migrations/0004_botsubscriber_wechat_id.py
Normal file
18
backend/bots/migrations/0004_botsubscriber_wechat_id.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@ -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 "-"})'
|
||||
|
||||
@ -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,
|
||||
# })
|
||||
|
||||
@ -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 _
|
||||
|
||||
@ -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):
|
||||
|
||||
3
backend/communications/admin.py
Normal file
3
backend/communications/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/communications/apps.py
Normal file
6
backend/communications/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommunicationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'communications'
|
||||
3
backend/communications/models.py
Normal file
3
backend/communications/models.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
backend/communications/views.py
Normal file
3
backend/communications/views.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@ -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):
|
||||
|
||||
0
backend/employees/__init__.py
Normal file
0
backend/employees/__init__.py
Normal file
158
backend/employees/admin.py
Normal file
158
backend/employees/admin.py
Normal 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',)
|
||||
9
backend/employees/apps.py
Normal file
9
backend/employees/apps.py
Normal file
@ -0,0 +1,9 @@
|
||||
# employees/apps.py
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EmployeesConfig(AppConfig):
|
||||
name = 'employees'
|
||||
|
||||
def ready(self):
|
||||
import employees.signals
|
||||
26
backend/employees/constants.py
Normal file
26
backend/employees/constants.py
Normal 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, 'Восстановлен'),
|
||||
]
|
||||
@ -76,9 +76,9 @@ class ProfileUpdateForm(AvatarValidationMixin, UserChangeForm):
|
||||
'birth_date',
|
||||
'gender',
|
||||
'email',
|
||||
'position',
|
||||
'phone_number',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'wechat',
|
||||
'avatar'
|
||||
)
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
0
backend/employees/migrations/__init__.py
Normal file
0
backend/employees/migrations/__init__.py
Normal file
221
backend/employees/models.py
Normal file
221
backend/employees/models.py
Normal 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})'
|
||||
@ -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='Автоматически: принят при регистрации'
|
||||
)
|
||||
3
backend/employees/tests.py
Normal file
3
backend/employees/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
backend/employees/urls_front.py
Normal file
14
backend/employees/urls_front.py
Normal 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'),
|
||||
]
|
||||
86
backend/employees/views_front.py
Normal file
86
backend/employees/views_front.py
Normal 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,
|
||||
})
|
||||
@ -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(',')
|
||||
@ -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
0
backend/feed/__init__.py
Normal file
13
backend/feed/admin.py
Normal file
13
backend/feed/admin.py
Normal 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
6
backend/feed/apps.py
Normal 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
25
backend/feed/forms.py
Normal 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}),
|
||||
}
|
||||
48
backend/feed/migrations/0001_initial.py
Normal file
48
backend/feed/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
backend/feed/migrations/0002_post_image.py
Normal file
18
backend/feed/migrations/0002_post_image.py
Normal 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/'),
|
||||
),
|
||||
]
|
||||
0
backend/feed/migrations/__init__.py
Normal file
0
backend/feed/migrations/__init__.py
Normal file
48
backend/feed/models.py
Normal file
48
backend/feed/models.py
Normal 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
3
backend/feed/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
backend/feed/urls.py
Normal file
14
backend/feed/urls.py
Normal 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
126
backend/feed/views.py
Normal 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)
|
||||
0
backend/requests_app/__init__.py
Normal file
0
backend/requests_app/__init__.py
Normal file
3
backend/requests_app/admin.py
Normal file
3
backend/requests_app/admin.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
backend/requests_app/apps.py
Normal file
6
backend/requests_app/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RequestsAppConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'requests_app'
|
||||
42
backend/requests_app/forms.py
Normal file
42
backend/requests_app/forms.py
Normal 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': 'Измените статус, чтобы согласовать или отклонить заявление.',
|
||||
}
|
||||
35
backend/requests_app/migrations/0001_initial.py
Normal file
35
backend/requests_app/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/requests_app/migrations/__init__.py
Normal file
0
backend/requests_app/migrations/__init__.py
Normal file
37
backend/requests_app/models.py
Normal file
37
backend/requests_app/models.py
Normal 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()})"
|
||||
|
||||
3
backend/requests_app/tests.py
Normal file
3
backend/requests_app/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
14
backend/requests_app/urls.py
Normal file
14
backend/requests_app/urls.py
Normal 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'),
|
||||
|
||||
]
|
||||
90
backend/requests_app/views.py
Normal file
90
backend/requests_app/views.py
Normal 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})
|
||||
@ -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>
|
||||
|
||||
11
backend/templates/employees/absence_form.html
Normal file
11
backend/templates/employees/absence_form.html
Normal 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 %}
|
||||
22
backend/templates/employees/department_detail.html
Normal file
22
backend/templates/employees/department_detail.html
Normal 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 %}
|
||||
16
backend/templates/employees/department_list.html
Normal file
16
backend/templates/employees/department_list.html
Normal 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 %}
|
||||
38
backend/templates/employees/employees_list.html
Normal file
38
backend/templates/employees/employees_list.html
Normal 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 %}
|
||||
98
backend/templates/employees/profile.html
Normal file
98
backend/templates/employees/profile.html
Normal 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 %}
|
||||
69
backend/templates/feed/department_feed.html
Normal file
69
backend/templates/feed/department_feed.html
Normal 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 %}
|
||||
41
backend/templates/feed/employee_feed.html
Normal file
41
backend/templates/feed/employee_feed.html
Normal 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 %}
|
||||
79
backend/templates/feed/feed_list.html
Normal file
79
backend/templates/feed/feed_list.html
Normal 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 %}
|
||||
65
backend/templates/feed/post_detail.html
Normal file
65
backend/templates/feed/post_detail.html
Normal 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 %}
|
||||
17
backend/templates/feed/post_form.html
Normal file
17
backend/templates/feed/post_form.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
55
backend/templates/requests_app/all_requests.html
Normal file
55
backend/templates/requests_app/all_requests.html
Normal 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 %}
|
||||
67
backend/templates/requests_app/my_requests.html
Normal file
67
backend/templates/requests_app/my_requests.html
Normal 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 %}
|
||||
27
backend/templates/requests_app/request_confirm_delete.html
Normal file
27
backend/templates/requests_app/request_confirm_delete.html
Normal 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 %}
|
||||
42
backend/templates/requests_app/request_detail.html
Normal file
42
backend/templates/requests_app/request_detail.html
Normal 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 %}
|
||||
19
backend/templates/requests_app/request_form.html
Normal file
19
backend/templates/requests_app/request_form.html
Normal 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 %}
|
||||
31
backend/templates/requests_app/request_process.html
Normal file
31
backend/templates/requests_app/request_process.html
Normal 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 %}
|
||||
@ -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)
|
||||
@ -1,9 +0,0 @@
|
||||
# users/apps.py
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
name = 'users'
|
||||
|
||||
def ready(self):
|
||||
import users.signals # noqa
|
||||
@ -1,5 +0,0 @@
|
||||
GENDER_CHOICES = (
|
||||
(1, 'Мужской'),
|
||||
(2, 'Женский'),
|
||||
(0, 'Не указан'),
|
||||
)
|
||||
@ -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='Пол'),
|
||||
),
|
||||
]
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -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}'
|
||||
@ -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')
|
||||
Loading…
x
Reference in New Issue
Block a user