bf5310e489
Login stays visible only to self/manager/HR; draft report docs stay department-head only. Co-authored-by: Cursor <cursoragent@cursor.com>
262 lines
10 KiB
Python
262 lines
10 KiB
Python
from django.contrib.auth import get_user_model
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.db.models import Count, Prefetch, Q
|
|
from django.http import Http404
|
|
from django.shortcuts import get_object_or_404, redirect
|
|
from django.views.decorators.http import require_POST
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.tasks.models import Task, TaskComment
|
|
from core.models.directories import Directory
|
|
from core.utils.htmx import render_page
|
|
from core.utils.permissions import is_owner, is_owner_or_staff
|
|
|
|
Employee = get_user_model()
|
|
|
|
STATUS_FILTERS = {'pending', 'in_progress', 'done'}
|
|
|
|
# Участники задачи показываются с должностью (emp.post.name) — подгружаем
|
|
# её вместе с prefetch, чтобы не бить БД на каждого участника в шаблоне
|
|
_EMPLOYEES_WITH_POST = Employee.objects.select_related('post')
|
|
ROLE_PREFETCH = [
|
|
Prefetch('responsible', queryset=_EMPLOYEES_WITH_POST),
|
|
Prefetch('co_executors', queryset=_EMPLOYEES_WITH_POST),
|
|
Prefetch('observers', queryset=_EMPLOYEES_WITH_POST),
|
|
]
|
|
|
|
|
|
def can_edit_task(user, task) -> bool:
|
|
"""Редактировать может автор или администратор"""
|
|
return is_owner_or_staff(user, task, 'creator_id')
|
|
|
|
|
|
def can_delete_task(user, task) -> bool:
|
|
"""Удалять с портала может только автор"""
|
|
return is_owner(user, task, 'creator_id')
|
|
|
|
|
|
def _employee_dict(emp):
|
|
return {
|
|
'pk': emp.pk,
|
|
'label': emp.get_full_name() or '—',
|
|
'position': emp.post.name if emp.post_id else '',
|
|
'photo': emp.photo.url if emp.photo else '',
|
|
}
|
|
|
|
|
|
def _department_tree():
|
|
"""Дерево відділів (для навігації у пікері) + список без відділу.
|
|
Кожен вузол: {pk, name, children: [...], employees: [...]}."""
|
|
depts = list(
|
|
Directory.objects.filter(directory_type__code='department', is_active=True)
|
|
.select_related('parent')
|
|
.order_by('name')
|
|
)
|
|
employees = list(
|
|
Employee.objects.filter(is_active=True)
|
|
.select_related('post', 'department')
|
|
.order_by('last_name', 'first_name')
|
|
)
|
|
|
|
by_dept = {}
|
|
for emp in employees:
|
|
by_dept.setdefault(emp.department_id, []).append(emp)
|
|
|
|
nodes = {
|
|
d.pk: {
|
|
'pk': d.pk, 'name': d.name, 'children': [],
|
|
'employees': [_employee_dict(e) for e in by_dept.get(d.pk, [])],
|
|
}
|
|
for d in depts
|
|
}
|
|
roots = []
|
|
for d in depts:
|
|
node = nodes[d.pk]
|
|
parent_node = nodes.get(d.parent_id)
|
|
if parent_node is not None:
|
|
parent_node['children'].append(node)
|
|
else:
|
|
roots.append(node)
|
|
|
|
no_department = [_employee_dict(e) for e in by_dept.get(None, [])]
|
|
return roots, no_department, employees
|
|
|
|
|
|
def _form_choices():
|
|
"""Дані для пікерів людей: плоский список (джерело істини для <select>)
|
|
та дерево відділів (для навігації по ньому у віджеті)."""
|
|
tree, no_department, employees = _department_tree()
|
|
return {
|
|
'employee_options': [_employee_dict(e) for e in employees],
|
|
'department_tree': {'tree': tree, 'no_department': no_department},
|
|
'priorities': Task.PRIORITY_CHOICES,
|
|
'empty_ids': set(),
|
|
}
|
|
|
|
|
|
def _apply_form(task, request):
|
|
"""Заполнение задачи из формы; возвращает текст ошибки или ''"""
|
|
title = request.POST.get('title', '').strip()
|
|
if not title:
|
|
return 'Задачу не збережено: вкажіть назву.'
|
|
|
|
task.title = title
|
|
task.description = request.POST.get('description', '').strip()
|
|
priority = request.POST.get('priority', 'medium')
|
|
task.priority = priority if priority in dict(Task.PRIORITY_CHOICES) else 'medium'
|
|
task.due_date = request.POST.get('due_date') or None
|
|
task.save()
|
|
|
|
active_employees = Employee.objects.filter(is_active=True)
|
|
task.responsible.set(active_employees.filter(pk__in=request.POST.getlist('responsible')))
|
|
task.co_executors.set(active_employees.filter(pk__in=request.POST.getlist('co_executors')))
|
|
task.observers.set(active_employees.filter(pk__in=request.POST.getlist('observers')))
|
|
return ''
|
|
|
|
|
|
@login_required
|
|
def tasks_view(request):
|
|
"""Мои задачи: создал сам или назначен напрямую"""
|
|
form_error = ''
|
|
if request.method == 'POST':
|
|
task = Task(creator=request.user)
|
|
form_error = _apply_form(task, request)
|
|
|
|
# Перехід зі сторінки співробітника ("Призначити задачу") —
|
|
# відкриваємо форму одразу з обраним відповідальним
|
|
preselect_responsible_ids = set()
|
|
raw_responsible = request.GET.get('responsible', '')
|
|
if raw_responsible.isdigit() and Employee.objects.filter(pk=raw_responsible, is_active=True).exists():
|
|
preselect_responsible_ids = {int(raw_responsible)}
|
|
|
|
active_status = request.GET.get('status', '')
|
|
if active_status not in STATUS_FILTERS:
|
|
active_status = ''
|
|
|
|
base = Task.objects.for_user(request.user)
|
|
# Один aggregate-запрос вместо четырёх .count() — distinct=True считает
|
|
# уникальные pk, что корректно даже с дублями строк от JOIN по M2M
|
|
counts = base.aggregate(
|
|
all=Count('pk', distinct=True),
|
|
pending=Count('pk', distinct=True, filter=Q(status='pending')),
|
|
in_progress=Count('pk', distinct=True, filter=Q(status='in_progress')),
|
|
done=Count('pk', distinct=True, filter=Q(status='done')),
|
|
)
|
|
|
|
visible = base.select_related('creator').prefetch_related(*ROLE_PREFETCH)
|
|
tasks = visible.filter(status=active_status) if active_status else visible.exclude(status='cancelled')
|
|
tasks = list(tasks[:100])
|
|
for task in tasks:
|
|
task.can_edit = can_edit_task(request.user, task)
|
|
task.can_delete = can_delete_task(request.user, task)
|
|
# Для короткого підсумку в рядку списку: відповідальний + співвиконавці
|
|
# (обидва вже prefetch-нуті — .all() не робить нових запитів)
|
|
task.display_people = list(task.responsible.all()) + list(task.co_executors.all())
|
|
|
|
context = {
|
|
'tasks': tasks,
|
|
'counts': counts,
|
|
'active_status': active_status,
|
|
'form_error': form_error,
|
|
'form_open': bool(form_error or preselect_responsible_ids),
|
|
'preselect_responsible_ids': preselect_responsible_ids,
|
|
**_form_choices(),
|
|
}
|
|
return render_page(request, 'tasks.html', context)
|
|
|
|
|
|
def _get_visible_task(request, pk):
|
|
task = get_object_or_404(
|
|
Task.objects.select_related('creator').prefetch_related(*ROLE_PREFETCH),
|
|
pk=pk,
|
|
)
|
|
visible = (
|
|
request.user.is_staff
|
|
or Task.objects.for_user(request.user).filter(pk=task.pk).exists()
|
|
)
|
|
if not visible:
|
|
raise Http404
|
|
return task
|
|
|
|
|
|
@login_required
|
|
def task_detail_view(request, pk):
|
|
"""Задача: описание, участники, смена статуса, комментарии"""
|
|
task = _get_visible_task(request, pk)
|
|
is_participant = task.is_participant(request.user)
|
|
|
|
if request.method == 'POST':
|
|
action = request.POST.get('action')
|
|
if action == 'status' and is_participant:
|
|
status = request.POST.get('status')
|
|
if status in dict(Task.STATUS_CHOICES):
|
|
task.set_status(status)
|
|
elif action == 'comment':
|
|
text = request.POST.get('text', '').strip()
|
|
if text:
|
|
TaskComment.objects.create(task=task, author=request.user, text=text)
|
|
elif action == 'checklist_toggle' and is_participant:
|
|
item = task.checklist.filter(pk=request.POST.get('item_id')).first()
|
|
if item:
|
|
item.set_done(not item.is_done, user=request.user)
|
|
|
|
context = {
|
|
'task': task,
|
|
'comments': task.comments.select_related('author'),
|
|
'checklist': task.checklist.select_related('done_by'),
|
|
'is_participant': is_participant,
|
|
'can_edit': can_edit_task(request.user, task),
|
|
'can_delete': can_delete_task(request.user, task),
|
|
'role_groups': [
|
|
(_('Ответственный'), list(task.responsible.all())),
|
|
(_('Соисполнители'), list(task.co_executors.all())),
|
|
(_('Наблюдатели'), list(task.observers.all())),
|
|
],
|
|
}
|
|
return render_page(request, 'task-detail.html', context)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def task_toggle_view(request, pk):
|
|
"""Чекбокс в списке: выполнено <-> в ожидании"""
|
|
task = _get_visible_task(request, pk)
|
|
if task.is_participant(request.user):
|
|
task.set_status('pending' if task.status == 'done' else 'done')
|
|
return redirect('tasks')
|
|
|
|
|
|
@login_required
|
|
def task_edit_view(request, pk):
|
|
"""Редактирование: автор или администратор"""
|
|
task = get_object_or_404(Task.objects.prefetch_related(*ROLE_PREFETCH), pk=pk)
|
|
if not can_edit_task(request.user, task):
|
|
raise Http404
|
|
|
|
form_error = ''
|
|
if request.method == 'POST':
|
|
form_error = _apply_form(task, request)
|
|
if not form_error:
|
|
return redirect('task_detail', pk=task.pk)
|
|
|
|
context = {
|
|
'task': task,
|
|
'form_error': form_error,
|
|
'responsible_ids': {e.pk for e in task.responsible.all()},
|
|
'co_executor_ids': {e.pk for e in task.co_executors.all()},
|
|
'observer_ids': {e.pk for e in task.observers.all()},
|
|
**_form_choices(),
|
|
}
|
|
return render_page(request, 'task-edit.html', context)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def task_delete_view(request, pk):
|
|
"""Удаление: только автор"""
|
|
task = get_object_or_404(Task, pk=pk)
|
|
if not can_delete_task(request.user, task):
|
|
raise Http404
|
|
task.delete()
|
|
return redirect('tasks')
|