projects: модуль проектов с шагами по отделам и Task.

Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 16:26:45 +03:00
parent 69ae14f65e
commit 030bbc98fd
28 changed files with 2129 additions and 3 deletions
+248
View File
@@ -0,0 +1,248 @@
"""Портал: список / карточка проектов, создание, активация."""
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)