03019898b0
Добавлена офлайн/EndUser-подпись «ознакомлен и принял в работу» для документов отчётов и файловые вложения к задачам через Nextcloud. Co-authored-by: Cursor <cursoragent@cursor.com>
383 lines
15 KiB
Python
383 lines
15 KiB
Python
import logging
|
|
from urllib.parse import quote
|
|
|
|
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, HttpResponse, HttpResponseForbidden
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.views.decorators.http import require_POST
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from apps.tasks.attachments import (
|
|
attachment_relpath,
|
|
can_delete_attachment,
|
|
unique_stored_name,
|
|
)
|
|
from apps.tasks.models import Task, TaskAttachment, TaskComment
|
|
from core.models.directories import Directory
|
|
from core.utils.htmx import is_htmx_request, render_page
|
|
from core.utils.permissions import is_owner, is_owner_or_staff
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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
|
|
|
|
|
|
def _attachments_for(task, user):
|
|
items = list(task.attachments.select_related('uploaded_by'))
|
|
for att in items:
|
|
att.can_delete = can_delete_attachment(user, att)
|
|
return items
|
|
|
|
|
|
def _attachments_context(request, task, message='', message_type=''):
|
|
return {
|
|
'task': task,
|
|
'attachments': _attachments_for(task, request.user),
|
|
'attach_message': message,
|
|
'attach_message_type': message_type,
|
|
}
|
|
|
|
|
|
def _render_attachments(request, task, message='', message_type=''):
|
|
ctx = _attachments_context(request, task, message, message_type)
|
|
if is_htmx_request(request):
|
|
return render(request, 'partials/task-attachments.html', ctx)
|
|
return redirect('task_detail', pk=task.pk)
|
|
|
|
|
|
@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())),
|
|
],
|
|
**_attachments_context(request, task),
|
|
}
|
|
return render_page(request, 'task-detail.html', context)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def task_attachment_upload_view(request, pk):
|
|
"""Загрузка файла к задаче (видят участники задачи)."""
|
|
task = _get_visible_task(request, pk)
|
|
f = request.FILES.get('file')
|
|
if not f:
|
|
return _render_attachments(request, task, 'Файл не выбран.', 'error')
|
|
|
|
from apps.nextcloud_connector.views import MAX_UPLOAD_BYTES
|
|
|
|
if f.size > MAX_UPLOAD_BYTES:
|
|
return _render_attachments(request, task, 'Файл больше 100 МБ.', 'error')
|
|
|
|
stored = unique_stored_name(task.pk, f.name)
|
|
if not stored:
|
|
return _render_attachments(request, task, 'Недопустимое имя файла.', 'error')
|
|
|
|
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
|
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
|
|
|
content = f.read()
|
|
ctype = (f.content_type or '')[:120]
|
|
att = TaskAttachment.objects.create(
|
|
task=task,
|
|
original_name=f.name[:255],
|
|
stored_name=stored,
|
|
content_type=ctype,
|
|
size=len(content),
|
|
uploaded_by=request.user,
|
|
)
|
|
try:
|
|
NextcloudWebDAVService().upload(attachment_relpath(att), content, ctype or None)
|
|
except NextcloudConfigError:
|
|
att.delete()
|
|
return _render_attachments(
|
|
request, task, 'Файловое хранилище (Nextcloud) ещё не настроено.', 'error',
|
|
)
|
|
except NextcloudServiceError as e:
|
|
logger.warning('Nextcloud upload (задача %s, %s): %s', task.pk, stored, e)
|
|
att.delete()
|
|
return _render_attachments(request, task, 'Не удалось загрузить файл.', 'error')
|
|
|
|
return _render_attachments(request, task, f'Файл «{att.original_name}» загружен.', 'success')
|
|
|
|
|
|
@login_required
|
|
def task_attachment_download_view(request, pk, att_pk):
|
|
task = _get_visible_task(request, pk)
|
|
att = get_object_or_404(TaskAttachment, pk=att_pk, task=task)
|
|
|
|
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
|
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
|
|
|
try:
|
|
content, ctype = NextcloudWebDAVService().download(attachment_relpath(att))
|
|
except (NextcloudConfigError, NextcloudServiceError) as e:
|
|
logger.warning('Nextcloud download (задача %s, %s): %s', task.pk, att.stored_name, e)
|
|
raise Http404('Файл недоступен.')
|
|
|
|
media = att.content_type or ctype or 'application/octet-stream'
|
|
resp = HttpResponse(content, content_type=media)
|
|
resp['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(att.original_name)}"
|
|
resp['X-Content-Type-Options'] = 'nosniff'
|
|
return resp
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def task_attachment_delete_view(request, pk, att_pk):
|
|
task = _get_visible_task(request, pk)
|
|
att = get_object_or_404(TaskAttachment, pk=att_pk, task=task)
|
|
if not can_delete_attachment(request.user, att):
|
|
return HttpResponseForbidden('Недостаточно прав для удаления.')
|
|
|
|
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
|
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
|
|
|
name = att.original_name
|
|
try:
|
|
NextcloudWebDAVService().delete(attachment_relpath(att))
|
|
except (NextcloudConfigError, NextcloudServiceError) as e:
|
|
logger.warning('Nextcloud delete (задача %s, %s): %s', task.pk, att.stored_name, e)
|
|
att.delete()
|
|
return _render_attachments(request, task, f'Файл «{name}» удалён.', 'success')
|
|
|
|
|
|
@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')
|