030bbc98fd
Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media. Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
9.1 KiB
Python
249 lines
9.1 KiB
Python
"""Портал: список / карточка проектов, создание, активация."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.db.models import Prefetch
|
|
from django.http import HttpResponseForbidden
|
|
from django.shortcuts import get_object_or_404, redirect
|
|
from django.views.decorators.http import require_POST
|
|
|
|
from apps.projects.models import (
|
|
Project, ProjectStep, ProjectStepItem, ProjectTemplate,
|
|
)
|
|
from apps.projects.services import (
|
|
activate_project, activate_step,
|
|
create_manual_project, create_project_from_template,
|
|
)
|
|
from core.models.directories import Directory
|
|
from core.portal_access import section_required
|
|
from core.utils.htmx import render_page
|
|
|
|
|
|
def _parse_date(raw: str):
|
|
raw = (raw or '').strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
return datetime.strptime(raw, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _can_manage_project(user, project: Project) -> bool:
|
|
if not user or not user.is_authenticated:
|
|
return False
|
|
if user.is_staff or user.is_superuser:
|
|
return True
|
|
return project.owner_id == user.pk or project.created_by_id == user.pk
|
|
|
|
|
|
def _departments():
|
|
return list(
|
|
Directory.objects.filter(
|
|
directory_type__code='department', is_active=True,
|
|
).order_by('name')
|
|
)
|
|
|
|
|
|
def _project_qs():
|
|
return (
|
|
Project.objects.select_related('owner', 'pharmacy', 'template')
|
|
.prefetch_related(
|
|
Prefetch(
|
|
'steps',
|
|
queryset=ProjectStep.objects.select_related('department').prefetch_related(
|
|
Prefetch(
|
|
'items',
|
|
queryset=ProjectStepItem.objects.select_related(
|
|
'task', 'task__creator',
|
|
).prefetch_related('task__responsible'),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
@login_required
|
|
@section_required('projects')
|
|
def projects_list_view(request):
|
|
status = (request.GET.get('status') or '').strip()
|
|
qs = Project.objects.for_user(request.user).select_related(
|
|
'owner', 'pharmacy', 'template',
|
|
)
|
|
if status in {
|
|
Project.STATUS_DRAFT, Project.STATUS_ACTIVE,
|
|
Project.STATUS_DONE, Project.STATUS_CANCELLED,
|
|
}:
|
|
qs = qs.filter(status=status)
|
|
projects = list(qs[:200])
|
|
for p in projects:
|
|
p.done_count, p.total_count = p.progress_counts()
|
|
p.pct = p.progress_percent
|
|
return render_page(request, 'projects.html', {
|
|
'projects': projects,
|
|
'active_status': status,
|
|
'templates': list(
|
|
ProjectTemplate.objects.filter(is_active=True).order_by('name')
|
|
),
|
|
'departments': _departments(),
|
|
'ug_message': request.session.pop('projects_message', ''),
|
|
'ug_error': request.session.pop('projects_error', ''),
|
|
})
|
|
|
|
|
|
@login_required
|
|
@section_required('projects')
|
|
def project_detail_view(request, pk: int):
|
|
project = get_object_or_404(_project_qs(), pk=pk)
|
|
if not Project.objects.for_user(request.user).filter(pk=project.pk).exists():
|
|
return HttpResponseForbidden('Нет доступа к проекту.')
|
|
done, total = project.progress_counts()
|
|
return render_page(request, 'project-detail.html', {
|
|
'project': project,
|
|
'done_count': done,
|
|
'total_count': total,
|
|
'pct': project.progress_percent,
|
|
'can_manage': _can_manage_project(request.user, project),
|
|
'departments': _departments(),
|
|
'ug_message': request.session.pop('projects_message', ''),
|
|
'ug_error': request.session.pop('projects_error', ''),
|
|
})
|
|
|
|
|
|
@login_required
|
|
@section_required('projects')
|
|
@require_POST
|
|
def project_create_view(request):
|
|
mode = (request.POST.get('mode') or 'manual').strip()
|
|
title = (request.POST.get('title') or '').strip()
|
|
description = (request.POST.get('description') or '').strip()
|
|
start_date = _parse_date(request.POST.get('start_date'))
|
|
due_date = _parse_date(request.POST.get('due_date'))
|
|
pharmacy_id = (request.POST.get('pharmacy_id') or '').strip()
|
|
pharmacy = None
|
|
if pharmacy_id.isdigit():
|
|
from apps.locations.models import Pharmacy
|
|
pharmacy = Pharmacy.objects.filter(pk=int(pharmacy_id)).first()
|
|
|
|
try:
|
|
if mode == 'template':
|
|
tid = (request.POST.get('template_id') or '').strip()
|
|
if not tid.isdigit():
|
|
raise ValueError('Выберите шаблон.')
|
|
template = get_object_or_404(
|
|
ProjectTemplate, pk=int(tid), is_active=True,
|
|
)
|
|
project = create_project_from_template(
|
|
template=template,
|
|
title=title or template.name,
|
|
owner=request.user,
|
|
created_by=request.user,
|
|
start_date=start_date,
|
|
due_date=due_date,
|
|
pharmacy=pharmacy,
|
|
description=description,
|
|
)
|
|
else:
|
|
if not title:
|
|
raise ValueError('Укажите название проекта.')
|
|
steps = []
|
|
step_titles = request.POST.getlist('step_title')
|
|
step_depts = request.POST.getlist('step_department_id')
|
|
step_items = request.POST.getlist('step_items')
|
|
for i, st in enumerate(step_titles):
|
|
st = (st or '').strip()
|
|
if not st:
|
|
continue
|
|
dept_raw = step_depts[i] if i < len(step_depts) else ''
|
|
items_raw = step_items[i] if i < len(step_items) else ''
|
|
items = []
|
|
for line in (items_raw or '').splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
items.append({'title': line})
|
|
if not items:
|
|
items = [{'title': st}]
|
|
steps.append({
|
|
'title': st,
|
|
'department_id': int(dept_raw) if str(dept_raw).isdigit() else None,
|
|
'order': i,
|
|
'items': items,
|
|
})
|
|
project = create_manual_project(
|
|
title=title,
|
|
owner=request.user,
|
|
created_by=request.user,
|
|
description=description,
|
|
start_date=start_date,
|
|
due_date=due_date,
|
|
pharmacy=pharmacy,
|
|
steps=steps,
|
|
)
|
|
except ValueError as exc:
|
|
request.session['projects_error'] = str(exc)
|
|
return redirect('projects')
|
|
|
|
request.session['projects_message'] = f'Проект «{project.title}» создан (черновик).'
|
|
return redirect('project_detail', pk=project.pk)
|
|
|
|
|
|
@login_required
|
|
@section_required('projects')
|
|
@require_POST
|
|
def project_activate_view(request, pk: int):
|
|
project = get_object_or_404(Project, pk=pk)
|
|
if not _can_manage_project(request.user, project):
|
|
return HttpResponseForbidden('Нет права запускать проект.')
|
|
try:
|
|
activate_project(project)
|
|
except ValueError as exc:
|
|
request.session['projects_error'] = str(exc)
|
|
return redirect('project_detail', pk=pk)
|
|
request.session['projects_message'] = 'Проект активирован, задачи созданы.'
|
|
return redirect('project_detail', pk=pk)
|
|
|
|
|
|
@login_required
|
|
@section_required('projects')
|
|
@require_POST
|
|
def project_step_activate_view(request, pk: int, step_id: int):
|
|
project = get_object_or_404(Project, pk=pk)
|
|
if not _can_manage_project(request.user, project):
|
|
return HttpResponseForbidden('Нет права запускать шаг.')
|
|
step = get_object_or_404(ProjectStep, pk=step_id, project=project)
|
|
activate_step(step)
|
|
request.session['projects_message'] = f'Шаг «{step.title}» запущен.'
|
|
return redirect('project_detail', pk=pk)
|
|
|
|
|
|
@login_required
|
|
@section_required('projects')
|
|
@require_POST
|
|
def project_add_step_view(request, pk: int):
|
|
project = get_object_or_404(Project, pk=pk)
|
|
if not _can_manage_project(request.user, project):
|
|
return HttpResponseForbidden('Нет права менять шаги.')
|
|
title = (request.POST.get('title') or '').strip()
|
|
if not title:
|
|
request.session['projects_error'] = 'Укажите название шага.'
|
|
return redirect('project_detail', pk=pk)
|
|
dept_raw = (request.POST.get('department_id') or '').strip()
|
|
order = project.steps.count()
|
|
step = ProjectStep.objects.create(
|
|
project=project,
|
|
title=title,
|
|
order=order,
|
|
department_id=int(dept_raw) if dept_raw.isdigit() else None,
|
|
planned_start=project.start_date,
|
|
planned_due=project.due_date,
|
|
)
|
|
items_raw = (request.POST.get('items') or '').strip()
|
|
lines = [ln.strip() for ln in items_raw.splitlines() if ln.strip()] or [title]
|
|
for j, line in enumerate(lines):
|
|
ProjectStepItem.objects.create(step=step, title=line, order=j)
|
|
request.session['projects_message'] = f'Шаг «{title}» добавлен.'
|
|
return redirect('project_detail', pk=pk)
|