Files
ACORE/apps/tasks/views.py
T
lokses 03019898b0 reports: КЕП-подпись PDF отчётов; задачи: загрузка вложений.
Добавлена офлайн/EndUser-подпись «ознакомлен и принял в работу» для документов отчётов и файловые вложения к задачам через Nextcloud.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 21:13:28 +03:00

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')