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
+9
View File
@@ -52,3 +52,12 @@ PORTAL_BASE_URL=http://localhost:8000
# CELERY_BROKER_URL=redis://:CHANGE_ME@127.0.0.1:6379/0
# CELERY_RESULT_BACKEND=redis://:CHANGE_ME@127.0.0.1:6379/0
# REDIS_ALLOW_UNAUTHENTICATED=1
# SILO / S3-compatible media (django-storages). Docs: docs/silo.md
# When set, ImageField/FileField go to bucket; leave empty for local MEDIA_ROOT.
# S3_ENDPOINT=https://s3.example.local:9000
# S3_ACCESS_KEY=acore-media-rw
# S3_SECRET_KEY=
# S3_MEDIA_BUCKET=acore-media
# S3_REGION=us-east-1
# S3_QUERYSTRING_EXPIRE=3600
+46
View File
@@ -105,6 +105,7 @@ INSTALLED_APPS = [
'apps.reports',
'apps.feed',
'apps.tasks',
'apps.projects',
'apps.hiring',
'apps.utilities',
'apps.power_assets',
@@ -189,6 +190,41 @@ STATICFILES_DIRS = [BASE_DIR / 'static']
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# SILO / S3-compatible object storage for Django media (photos, uploads).
# Enabled only when endpoint + keys are set; otherwise local MEDIA_ROOT (dev).
# Documents stay on Nextcloud WebDAV — see docs/silo.md.
S3_ENDPOINT = env('S3_ENDPOINT', default='').rstrip('/')
S3_ACCESS_KEY = env('S3_ACCESS_KEY', default='')
S3_SECRET_KEY = env('S3_SECRET_KEY', default='')
S3_MEDIA_BUCKET = env('S3_MEDIA_BUCKET', default='acore-media')
S3_REGION = env('S3_REGION', default='us-east-1')
S3_QUERYSTRING_EXPIRE = env.int('S3_QUERYSTRING_EXPIRE', default=3600)
USE_S3_MEDIA = bool(S3_ENDPOINT and S3_ACCESS_KEY and S3_SECRET_KEY)
if USE_S3_MEDIA:
STORAGES = {
'default': {
'BACKEND': 'storages.backends.s3.S3Storage',
'OPTIONS': {
'access_key': S3_ACCESS_KEY,
'secret_key': S3_SECRET_KEY,
'bucket_name': S3_MEDIA_BUCKET,
'endpoint_url': S3_ENDPOINT,
'region_name': S3_REGION,
'addressing_style': 'path',
'signature_version': 's3v4',
'default_acl': None,
'querystring_auth': True,
'querystring_expire': S3_QUERYSTRING_EXPIRE,
'file_overwrite': False,
'object_parameters': {'CacheControl': 'max-age=86400'},
},
},
'staticfiles': {
'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
},
}
LOGIN_URL = '/accounts/login/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
@@ -550,6 +586,16 @@ UNFOLD = {
"icon": "task_alt",
"link": reverse_lazy("admin:tasks_task_changelist"),
},
{
"title": "Проекты",
"icon": "account_tree",
"link": reverse_lazy("admin:projects_project_changelist"),
},
{
"title": "Шаблоны проектов",
"icon": "schema",
"link": reverse_lazy("admin:projects_projecttemplate_changelist"),
},
],
},
{
+4 -1
View File
@@ -52,6 +52,7 @@ urlpatterns = [
path('', include('apps.ad_connector.urls')),
path('', include('apps.feed.urls')),
path('', include('apps.tasks.urls')),
path('', include('apps.projects.urls')),
path('', include('apps.it_manager.urls')),
path('', include('apps.reports.urls')),
path('', include('apps.mattermost_connector.urls')),
@@ -155,4 +156,6 @@ urlpatterns = [
if settings.DEBUG:
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Local media only when not using SILO/S3 (signed URLs otherwise).
if not getattr(settings, 'USE_S3_MEDIA', False):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
View File
+75
View File
@@ -0,0 +1,75 @@
from django.contrib import admin
from unfold.admin import ModelAdmin, TabularInline
from apps.projects.models import (
Project, ProjectStep, ProjectStepItem,
ProjectTemplate, ProjectTemplateStep, ProjectTemplateStepItem,
)
class TemplateStepItemInline(TabularInline):
model = ProjectTemplateStepItem
extra = 1
fields = ('order', 'title', 'priority', 'description')
class TemplateStepInline(TabularInline):
model = ProjectTemplateStep
extra = 1
fields = ('order', 'title', 'department', 'offset_days', 'duration_days')
show_change_link = True
autocomplete_fields = ['department']
@admin.register(ProjectTemplate)
class ProjectTemplateAdmin(ModelAdmin):
list_display = ('name', 'default_duration_days', 'is_active', 'updated_at')
list_filter = ('is_active',)
search_fields = ('name', 'description')
inlines = [TemplateStepInline]
@admin.register(ProjectTemplateStep)
class ProjectTemplateStepAdmin(ModelAdmin):
list_display = ('template', 'order', 'title', 'department', 'offset_days', 'duration_days')
list_filter = ('template',)
search_fields = ('title',)
autocomplete_fields = ['template', 'department']
inlines = [TemplateStepItemInline]
class ProjectStepItemInline(TabularInline):
model = ProjectStepItem
extra = 0
fields = ('order', 'title', 'priority', 'task')
readonly_fields = ('task',)
show_change_link = True
class ProjectStepInline(TabularInline):
model = ProjectStep
extra = 0
fields = ('order', 'title', 'department', 'status', 'planned_start', 'planned_due')
show_change_link = True
autocomplete_fields = ['department']
@admin.register(Project)
class ProjectAdmin(ModelAdmin):
list_display = (
'title', 'status', 'owner', 'pharmacy', 'start_date', 'due_date', 'updated_at',
)
list_filter = ('status',)
search_fields = ('title', 'description')
autocomplete_fields = ['owner', 'created_by', 'pharmacy', 'template']
inlines = [ProjectStepInline]
readonly_fields = ('uuid', 'created_at', 'updated_at')
@admin.register(ProjectStep)
class ProjectStepAdmin(ModelAdmin):
list_display = ('project', 'order', 'title', 'department', 'status', 'planned_due')
list_filter = ('status',)
search_fields = ('title', 'project__title')
autocomplete_fields = ['project', 'department']
inlines = [ProjectStepItemInline]
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.projects'
verbose_name = 'Проекты'
def ready(self):
import apps.projects.signals # noqa: F401
@@ -0,0 +1,153 @@
"""Сид шаблона «Открытие аптеки» с типовыми шагами по отделам.
python manage.py seed_project_templates
"""
from __future__ import annotations
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.projects.models import (
ProjectTemplate, ProjectTemplateStep, ProjectTemplateStepItem,
)
from core.models.directories import Directory
# (step_title, dept_code_or_name_hints, offset_days, duration_days, [item titles])
PHARMACY_OPENING_STEPS = [
(
'Подготовка помещения и договора',
['юрист', 'legal', 'юридич'],
0, 21,
[
'Согласовать договор аренды',
'Проверить адрес и зону лицензирования',
'Получить технический паспорт / планы',
],
),
(
'Лицензирование',
['лиценз', 'фармацевт', 'качест'],
7, 30,
[
'Подготовить пакет документов на лицензию',
'Подать заявление в регулятор',
'Получить лицензию / внести изменения',
],
),
(
'IT и коммуникации',
['it', 'ит', 'информац'],
14, 21,
[
'Заказать канал связи / интернет',
'Установить РРО / ПРРО и ПО кассы',
'Настроить учётки AD / портал / почту',
'Проверить видеонаблюдение и сигнализацию',
],
),
(
'Закупки и оборудование',
['закуп', 'снабж', 'логист'],
14, 28,
[
'Заказать торговое оборудование',
'Закупить стартовый товарный запас',
'Организовать доставку и приёмку',
],
),
(
'HR и персонал',
['hr', 'кадр', 'персонал'],
21, 30,
[
'Утвердить штатное расписание',
'Нанять заведующего и фармацевтов',
'Оформить допуски и обучение',
],
),
(
'Маркетинг и открытие',
['маркет', 'реклам', 'pr'],
45, 21,
[
'Подготовить вывеску и оформление',
'Анонс открытия / акции',
'Чек-лист дня открытия',
],
),
]
def _find_department(hints: list[str]):
depts = list(
Directory.objects.filter(
directory_type__code='department', is_active=True,
)
)
for hint in hints:
h = hint.lower()
for d in depts:
blob = f'{d.code or ""} {d.name or ""}'.lower()
if h in blob:
return d
return None
class Command(BaseCommand):
help = 'Seed project template «Открытие аптеки».'
@transaction.atomic
def handle(self, *args, **options):
tpl, created = ProjectTemplate.objects.get_or_create(
name='Открытие аптеки',
defaults={
'description': (
'Типовой процесс открытия новой аптеки (1–3 месяца): '
'лицензии, IT, закупки, HR, маркетинг.'
),
'default_duration_days': 90,
'is_active': True,
},
)
if not created:
tpl.description = tpl.description or (
'Типовой процесс открытия новой аптеки (1–3 месяца).'
)
tpl.default_duration_days = tpl.default_duration_days or 90
tpl.is_active = True
tpl.save()
# Refresh steps only if empty
if tpl.steps.exists():
self.stdout.write(
self.style.WARNING(
f'Template «{tpl.name}» already has steps — skipped rewrite.'
)
)
return
for order, (title, hints, offset, duration, items) in enumerate(
PHARMACY_OPENING_STEPS,
):
dept = _find_department(hints)
step = ProjectTemplateStep.objects.create(
template=tpl,
title=title,
order=order,
department=dept,
offset_days=offset,
duration_days=duration,
)
for j, item_title in enumerate(items):
ProjectTemplateStepItem.objects.create(
step=step,
title=item_title,
order=j,
priority='medium',
)
dept_label = dept.name if dept else '— (отдел не найден, назначьте в админке)'
self.stdout.write(f' + {title} → {dept_label}')
self.stdout.write(self.style.SUCCESS(
f'Seeded template «{tpl.name}» with {tpl.steps.count()} steps.'
))
+146
View File
@@ -0,0 +1,146 @@
# Generated by Django 6.0.3 on 2026-10-23 13:23
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0026_access_projects_perm'),
('locations', '0016_ukrainian_phone_field'),
('tasks', '0006_taskattachment'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProjectTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=200, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('default_duration_days', models.PositiveIntegerField(default=90, verbose_name='Длительность по умолчанию (дни)')),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
],
options={
'verbose_name': 'Шаблон проекта',
'verbose_name_plural': 'Шаблоны проектов',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=250, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('status', models.CharField(choices=[('draft', 'Черновик'), ('active', 'Активен'), ('done', 'Завершён'), ('cancelled', 'Отменён')], db_index=True, default='draft', max_length=20, verbose_name='Статус')),
('start_date', models.DateField(blank=True, null=True, verbose_name='Дата старта')),
('due_date', models.DateField(blank=True, null=True, verbose_name='Срок')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_projects', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_projects', to=settings.AUTH_USER_MODEL, verbose_name='Владелец')),
('pharmacy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='locations.pharmacy', verbose_name='Аптека')),
('template', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='projects.projecttemplate', verbose_name='Шаблон')),
],
options={
'verbose_name': 'Проект',
'verbose_name_plural': 'Проекты',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ProjectStep',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=250, verbose_name='Название')),
('order', models.PositiveSmallIntegerField(default=0, verbose_name='Порядок')),
('status', models.CharField(choices=[('pending', 'Ожидает'), ('in_progress', 'В работе'), ('done', 'Выполнен')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
('planned_start', models.DateField(blank=True, null=True, verbose_name='План. старт')),
('planned_due', models.DateField(blank=True, null=True, verbose_name='План. срок')),
('department', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'department'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_steps', to='core.directory', verbose_name='Отдел')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='projects.project', verbose_name='Проект')),
],
options={
'verbose_name': 'Шаг проекта',
'verbose_name_plural': 'Шаги проекта',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='ProjectStepItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=250, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('priority', models.CharField(default='medium', max_length=10, verbose_name='Приоритет')),
('order', models.PositiveSmallIntegerField(default=0, verbose_name='Порядок')),
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='projects.projectstep', verbose_name='Шаг')),
('task', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_step_item', to='tasks.task', verbose_name='Задача')),
],
options={
'verbose_name': 'Пункт шага',
'verbose_name_plural': 'Пункты шага',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='ProjectTemplateStep',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=250, verbose_name='Название шага')),
('order', models.PositiveSmallIntegerField(default=0, verbose_name='Порядок')),
('offset_days', models.PositiveIntegerField(default=0, help_text='Плановый старт шага = старт проекта + offset.', verbose_name='Смещение от старта (дни)')),
('duration_days', models.PositiveIntegerField(default=14, verbose_name='Длительность шага (дни)')),
('department', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'department'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_template_steps', to='core.directory', verbose_name='Отдел')),
('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='projects.projecttemplate', verbose_name='Шаблон')),
],
options={
'verbose_name': 'Шаг шаблона',
'verbose_name_plural': 'Шаги шаблона',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='ProjectTemplateStepItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(max_length=250, verbose_name='Название задачи')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], default='medium', max_length=10, verbose_name='Приоритет')),
('order', models.PositiveSmallIntegerField(default=0, verbose_name='Порядок')),
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='projects.projecttemplatestep', verbose_name='Шаг')),
],
options={
'verbose_name': 'Пункт шага шаблона',
'verbose_name_plural': 'Пункты шага шаблона',
'ordering': ['order', 'id'],
},
),
migrations.AddIndex(
model_name='project',
index=models.Index(fields=['status', '-created_at'], name='projects_pr_status_b6b628_idx'),
),
]
+257
View File
@@ -0,0 +1,257 @@
"""Проекты с шагами по отделам; пункты шагов связываются с tasks.Task."""
from __future__ import annotations
from django.conf import settings
from django.db import models
from django.db.models import Count, Q
from django.utils.translation import gettext_lazy as _
from core.models.base import BaseModel
class ProjectTemplate(BaseModel):
name = models.CharField(_('Название'), max_length=200)
description = models.TextField(_('Описание'), blank=True)
default_duration_days = models.PositiveIntegerField(
_('Длительность по умолчанию (дни)'), default=90,
)
is_active = models.BooleanField(_('Активен'), default=True)
class Meta:
verbose_name = _('Шаблон проекта')
verbose_name_plural = _('Шаблоны проектов')
ordering = ['name']
def __str__(self):
return self.name
class ProjectTemplateStep(BaseModel):
template = models.ForeignKey(
ProjectTemplate, on_delete=models.CASCADE,
related_name='steps', verbose_name=_('Шаблон'),
)
title = models.CharField(_('Название шага'), max_length=250)
order = models.PositiveSmallIntegerField(_('Порядок'), default=0)
department = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
null=True, blank=True,
limit_choices_to={'directory_type__code': 'department'},
related_name='project_template_steps', verbose_name=_('Отдел'),
)
offset_days = models.PositiveIntegerField(
_('Смещение от старта (дни)'), default=0,
help_text=_('Плановый старт шага = старт проекта + offset.'),
)
duration_days = models.PositiveIntegerField(
_('Длительность шага (дни)'), default=14,
)
class Meta:
verbose_name = _('Шаг шаблона')
verbose_name_plural = _('Шаги шаблона')
ordering = ['order', 'id']
def __str__(self):
return f'{self.order}. {self.title}'
class ProjectTemplateStepItem(BaseModel):
step = models.ForeignKey(
ProjectTemplateStep, on_delete=models.CASCADE,
related_name='items', verbose_name=_('Шаг'),
)
title = models.CharField(_('Название задачи'), max_length=250)
description = models.TextField(_('Описание'), blank=True)
priority = models.CharField(
_('Приоритет'), max_length=10, default='medium',
choices=[
('low', _('Низкий')),
('medium', _('Средний')),
('high', _('Высокий')),
],
)
order = models.PositiveSmallIntegerField(_('Порядок'), default=0)
class Meta:
verbose_name = _('Пункт шага шаблона')
verbose_name_plural = _('Пункты шага шаблона')
ordering = ['order', 'id']
def __str__(self):
return self.title
class ProjectQuerySet(models.QuerySet):
def for_user(self, user):
"""Владелец, участник связанных Task, сотрудник отдела шага, или staff."""
if not user or not getattr(user, 'is_authenticated', False):
return self.none()
if getattr(user, 'is_staff', False) or getattr(user, 'is_superuser', False):
return self.all()
dept_id = getattr(user, 'department_id', None)
q = Q(owner=user) | Q(created_by=user)
q |= Q(steps__items__task__creator=user)
q |= Q(steps__items__task__responsible=user)
q |= Q(steps__items__task__co_executors=user)
q |= Q(steps__items__task__observers=user)
if dept_id:
q |= Q(steps__department_id=dept_id)
return self.filter(q).distinct()
class Project(BaseModel):
STATUS_DRAFT = 'draft'
STATUS_ACTIVE = 'active'
STATUS_DONE = 'done'
STATUS_CANCELLED = 'cancelled'
STATUS_CHOICES = [
(STATUS_DRAFT, _('Черновик')),
(STATUS_ACTIVE, _('Активен')),
(STATUS_DONE, _('Завершён')),
(STATUS_CANCELLED, _('Отменён')),
]
title = models.CharField(_('Название'), max_length=250)
description = models.TextField(_('Описание'), blank=True)
status = models.CharField(
_('Статус'), max_length=20, choices=STATUS_CHOICES,
default=STATUS_DRAFT, db_index=True,
)
start_date = models.DateField(_('Дата старта'), null=True, blank=True)
due_date = models.DateField(_('Срок'), null=True, blank=True)
pharmacy = models.ForeignKey(
'locations.Pharmacy', on_delete=models.SET_NULL,
null=True, blank=True, related_name='projects',
verbose_name=_('Аптека'),
)
template = models.ForeignKey(
ProjectTemplate, on_delete=models.SET_NULL,
null=True, blank=True, related_name='projects',
verbose_name=_('Шаблон'),
)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='owned_projects', verbose_name=_('Владелец'),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, related_name='created_projects', verbose_name=_('Создал'),
)
objects = ProjectQuerySet.as_manager()
class Meta:
verbose_name = _('Проект')
verbose_name_plural = _('Проекты')
ordering = ['-created_at']
indexes = [
models.Index(fields=['status', '-created_at']),
]
def __str__(self):
return self.title
def progress_counts(self) -> tuple[int, int]:
"""(done_tasks, total_tasks)."""
agg = (
self.steps.filter(items__task__isnull=False)
.aggregate(
total=Count('items__task', distinct=True),
done=Count(
'items__task',
filter=Q(items__task__status='done'),
distinct=True,
),
)
)
return int(agg['done'] or 0), int(agg['total'] or 0)
@property
def progress_percent(self) -> int:
done, total = self.progress_counts()
if total <= 0:
return 0
return int(round(100 * done / total))
class ProjectStep(BaseModel):
STATUS_PENDING = 'pending'
STATUS_IN_PROGRESS = 'in_progress'
STATUS_DONE = 'done'
STATUS_CHOICES = [
(STATUS_PENDING, _('Ожидает')),
(STATUS_IN_PROGRESS, _('В работе')),
(STATUS_DONE, _('Выполнен')),
]
project = models.ForeignKey(
Project, on_delete=models.CASCADE,
related_name='steps', verbose_name=_('Проект'),
)
title = models.CharField(_('Название'), max_length=250)
order = models.PositiveSmallIntegerField(_('Порядок'), default=0)
department = models.ForeignKey(
'core.Directory', on_delete=models.SET_NULL,
null=True, blank=True,
limit_choices_to={'directory_type__code': 'department'},
related_name='project_steps', verbose_name=_('Отдел'),
)
status = models.CharField(
_('Статус'), max_length=20, choices=STATUS_CHOICES,
default=STATUS_PENDING, db_index=True,
)
planned_start = models.DateField(_('План. старт'), null=True, blank=True)
planned_due = models.DateField(_('План. срок'), null=True, blank=True)
class Meta:
verbose_name = _('Шаг проекта')
verbose_name_plural = _('Шаги проекта')
ordering = ['order', 'id']
def __str__(self):
return f'{self.order}. {self.title}'
def refresh_status(self, *, save: bool = True) -> str:
items = list(self.items.select_related('task'))
if not items:
new = self.STATUS_PENDING
else:
tasks = [i.task for i in items if i.task_id]
if not tasks:
new = self.STATUS_PENDING
elif all(t.status == 'done' for t in tasks):
new = self.STATUS_DONE
elif any(t.status in ('in_progress', 'done') for t in tasks):
new = self.STATUS_IN_PROGRESS
else:
new = self.STATUS_PENDING
if new != self.status:
self.status = new
if save:
self.save(update_fields=['status', 'updated_at'])
return self.status
class ProjectStepItem(BaseModel):
step = models.ForeignKey(
ProjectStep, on_delete=models.CASCADE,
related_name='items', verbose_name=_('Шаг'),
)
task = models.OneToOneField(
'tasks.Task', on_delete=models.SET_NULL,
null=True, blank=True, related_name='project_step_item',
verbose_name=_('Задача'),
)
title = models.CharField(_('Название'), max_length=250)
description = models.TextField(_('Описание'), blank=True)
priority = models.CharField(_('Приоритет'), max_length=10, default='medium')
order = models.PositiveSmallIntegerField(_('Порядок'), default=0)
class Meta:
verbose_name = _('Пункт шага')
verbose_name_plural = _('Пункты шага')
ordering = ['order', 'id']
def __str__(self):
return self.title
+193
View File
@@ -0,0 +1,193 @@
"""Создание проектов из шаблона / вручную и активация (Task)."""
from __future__ import annotations
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from apps.projects.models import (
Project, ProjectStep, ProjectStepItem,
ProjectTemplate,
)
from apps.tasks.models import Task
def _department_head(department_id):
try:
from apps.reports.it_monthly.builder import department_head
return department_head(department_id)
except Exception: # noqa: BLE001
return None
def _add_days(base, days: int):
if base is None:
return None
return base + timedelta(days=int(days or 0))
@transaction.atomic
def create_project_from_template(
*,
template: ProjectTemplate,
title: str,
owner,
created_by=None,
start_date=None,
due_date=None,
pharmacy=None,
description: str = '',
) -> Project:
start = start_date or timezone.localdate()
if due_date is None and template.default_duration_days:
due_date = _add_days(start, template.default_duration_days)
project = Project.objects.create(
title=(title or template.name).strip() or template.name,
description=(description or template.description or '').strip(),
status=Project.STATUS_DRAFT,
start_date=start,
due_date=due_date,
pharmacy=pharmacy,
template=template,
owner=owner,
created_by=created_by or owner,
)
for t_step in template.steps.prefetch_related('items').order_by('order', 'id'):
step = ProjectStep.objects.create(
project=project,
title=t_step.title,
order=t_step.order,
department=t_step.department,
status=ProjectStep.STATUS_PENDING,
planned_start=_add_days(start, t_step.offset_days),
planned_due=_add_days(start, t_step.offset_days + t_step.duration_days),
)
for t_item in t_step.items.order_by('order', 'id'):
ProjectStepItem.objects.create(
step=step,
title=t_item.title,
description=t_item.description,
priority=t_item.priority,
order=t_item.order,
)
return project
@transaction.atomic
def create_manual_project(
*,
title: str,
owner,
created_by=None,
description: str = '',
start_date=None,
due_date=None,
pharmacy=None,
steps: list[dict] | None = None,
) -> Project:
"""steps: [{title, department_id, order, items: [{title, description, priority}]}]"""
start = start_date or timezone.localdate()
project = Project.objects.create(
title=title.strip(),
description=(description or '').strip(),
status=Project.STATUS_DRAFT,
start_date=start,
due_date=due_date,
pharmacy=pharmacy,
owner=owner,
created_by=created_by or owner,
)
for idx, raw in enumerate(steps or []):
step = ProjectStep.objects.create(
project=project,
title=(raw.get('title') or f'Шаг {idx + 1}').strip(),
order=int(raw.get('order', idx)),
department_id=raw.get('department_id') or None,
status=ProjectStep.STATUS_PENDING,
planned_start=raw.get('planned_start') or start,
planned_due=raw.get('planned_due') or due_date,
)
for j, item in enumerate(raw.get('items') or []):
ProjectStepItem.objects.create(
step=step,
title=(item.get('title') or f'Задача {j + 1}').strip(),
description=(item.get('description') or '').strip(),
priority=item.get('priority') or 'medium',
order=int(item.get('order', j)),
)
return project
def _ensure_task_for_item(item: ProjectStepItem, *, owner) -> Task | None:
if item.task_id:
return item.task
step = item.step
head = _department_head(step.department_id)
task = Task.objects.create(
title=item.title,
description=item.description or '',
status='pending',
priority=item.priority or 'medium',
due_date=step.planned_due,
creator=owner,
)
responsible = head or owner
if responsible is not None:
task.responsible.add(responsible)
item.task = task
item.save(update_fields=['task', 'updated_at'])
return task
@transaction.atomic
def activate_project(project: Project) -> Project:
"""draft → active: создать Task для всех пунктов без задачи."""
if project.status == Project.STATUS_CANCELLED:
raise ValueError('Отменённый проект нельзя активировать.')
owner = project.owner or project.created_by
for step in project.steps.prefetch_related('items').order_by('order', 'id'):
for item in step.items.all():
_ensure_task_for_item(item, owner=owner)
step.refresh_status(save=True)
if project.status == Project.STATUS_DRAFT:
project.status = Project.STATUS_ACTIVE
if not project.start_date:
project.start_date = timezone.localdate()
project.save(update_fields=['status', 'start_date', 'updated_at'])
refresh_project_status(project)
return project
@transaction.atomic
def activate_step(step: ProjectStep) -> ProjectStep:
project = step.project
owner = project.owner or project.created_by
for item in step.items.all():
_ensure_task_for_item(item, owner=owner)
step.refresh_status(save=True)
if project.status == Project.STATUS_DRAFT:
project.status = Project.STATUS_ACTIVE
if not project.start_date:
project.start_date = timezone.localdate()
project.save(update_fields=['status', 'start_date', 'updated_at'])
refresh_project_status(project)
return step
def refresh_project_status(project: Project) -> str:
"""Авто-done, если все шаги done и есть хотя бы один шаг с задачами."""
steps = list(project.steps.all())
if not steps:
return project.status
for step in steps:
step.refresh_status(save=True)
steps = list(project.steps.all())
if project.status == Project.STATUS_ACTIVE and steps and all(
s.status == ProjectStep.STATUS_DONE for s in steps
):
project.status = Project.STATUS_DONE
project.save(update_fields=['status', 'updated_at'])
return project.status
+21
View File
@@ -0,0 +1,21 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.tasks.models import Task
@receiver(post_save, sender=Task)
def recalculate_project_on_task_change(sender, instance: Task, **kwargs):
from apps.projects.models import ProjectStepItem
from apps.projects.services import refresh_project_status
item = (
ProjectStepItem.objects
.filter(task_id=instance.pk)
.select_related('step__project')
.first()
)
if item is None:
return
item.step.refresh_status(save=True)
refresh_project_status(item.step.project)
+190
View File
@@ -0,0 +1,190 @@
"""Тесты модуля проектов: шаблон → Task, прогресс, видимость, permission."""
from __future__ import annotations
import datetime
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase
from apps.employees.models import Employee
from apps.projects.models import (
Project, ProjectStep, ProjectStepItem,
ProjectTemplate, ProjectTemplateStep, ProjectTemplateStepItem,
)
from apps.projects.services import (
activate_project, create_manual_project, create_project_from_template,
)
from apps.tasks.models import Task
from core.models import Directory, DirectoryType, PortalAccess
from core.test_utils import login_2fa
def _grant_projects(user):
ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False)
perm, _ = Permission.objects.get_or_create(
content_type=ct, codename='access_projects',
defaults={'name': 'Доступ к разделу: Проекты'},
)
user.user_permissions.add(perm)
class ProjectServicesTests(TestCase):
@classmethod
def setUpTestData(cls):
dtype = DirectoryType.objects.create(code='department', name='Відділ')
cls.dept_it = Directory.objects.create(
directory_type=dtype, code='it', name='IT',
)
cls.dept_hr = Directory.objects.create(
directory_type=dtype, code='hr', name='HR',
)
common = dict(
mattermost_sync_enabled=False, ad_sync_enabled=False,
hire_date=datetime.date(2020, 1, 1),
user_location_type='office',
)
cls.owner = Employee.objects.create_user(
username='proj_owner', department=cls.dept_it, **common,
)
cls.head = Employee.objects.create_user(
username='proj_head', department=cls.dept_it, **common,
)
cls.peer = Employee.objects.create_user(
username='proj_peer', department=cls.dept_it, manager=cls.head, **common,
)
# head is manager of peer → department_head heuristic
cls.outsider = Employee.objects.create_user(
username='proj_out', department=cls.dept_hr, **common,
)
cls.tpl = ProjectTemplate.objects.create(
name='Открытие аптеки', default_duration_days=90,
)
s1 = ProjectTemplateStep.objects.create(
template=cls.tpl, title='IT', order=0,
department=cls.dept_it, offset_days=0, duration_days=14,
)
ProjectTemplateStepItem.objects.create(
step=s1, title='Настроить сеть', order=0,
)
ProjectTemplateStepItem.objects.create(
step=s1, title='Установить кассу', order=1,
)
s2 = ProjectTemplateStep.objects.create(
template=cls.tpl, title='HR', order=1,
department=cls.dept_hr, offset_days=7, duration_days=21,
)
ProjectTemplateStepItem.objects.create(
step=s2, title='Нанять персонал', order=0,
)
def test_instantiate_template_creates_steps_and_tasks_on_activate(self):
project = create_project_from_template(
template=self.tpl,
title='Аптека тест',
owner=self.owner,
start_date=datetime.date(2026, 1, 1),
)
self.assertEqual(project.status, Project.STATUS_DRAFT)
self.assertEqual(project.steps.count(), 2)
self.assertEqual(
ProjectStepItem.objects.filter(step__project=project).count(), 3,
)
self.assertEqual(
ProjectStepItem.objects.filter(step__project=project, task__isnull=False).count(),
0,
)
activate_project(project)
project.refresh_from_db()
self.assertEqual(project.status, Project.STATUS_ACTIVE)
items = list(
ProjectStepItem.objects.filter(step__project=project).select_related('task')
)
self.assertEqual(len(items), 3)
self.assertTrue(all(i.task_id for i in items))
self.assertEqual(Task.objects.filter(pk__in=[i.task_id for i in items]).count(), 3)
def test_progress_and_auto_done(self):
project = create_project_from_template(
template=self.tpl, title='P2', owner=self.owner,
)
activate_project(project)
items = list(
ProjectStepItem.objects.filter(step__project=project).select_related('task')
)
self.assertEqual(project.progress_percent, 0)
for item in items:
item.task.set_status('done')
project.refresh_from_db()
self.assertEqual(project.progress_percent, 100)
self.assertEqual(project.status, Project.STATUS_DONE)
for step in project.steps.all():
self.assertEqual(step.status, ProjectStep.STATUS_DONE)
def test_for_user_visibility(self):
project = create_project_from_template(
template=self.tpl, title='Vis', owner=self.owner,
)
activate_project(project)
self.assertTrue(
Project.objects.for_user(self.owner).filter(pk=project.pk).exists()
)
# peer in IT department of a step
self.assertTrue(
Project.objects.for_user(self.peer).filter(pk=project.pk).exists()
)
# outsider HR department also has a step
self.assertTrue(
Project.objects.for_user(self.outsider).filter(pk=project.pk).exists()
)
stranger = Employee.objects.create_user(
username='stranger',
mattermost_sync_enabled=False, ad_sync_enabled=False,
hire_date=datetime.date(2020, 1, 1),
user_location_type='office',
)
self.assertFalse(
Project.objects.for_user(stranger).filter(pk=project.pk).exists()
)
def test_manual_create(self):
project = create_manual_project(
title='Ручной',
owner=self.owner,
steps=[{
'title': 'Шаг 1',
'department_id': self.dept_it.pk,
'items': [{'title': 'A'}, {'title': 'B'}],
}],
)
self.assertEqual(project.steps.count(), 1)
self.assertEqual(project.steps.first().items.count(), 2)
class ProjectPortalAccessTests(TestCase):
@classmethod
def setUpTestData(cls):
common = dict(
mattermost_sync_enabled=False, ad_sync_enabled=False,
hire_date=datetime.date(2020, 1, 1),
)
cls.user = Employee.objects.create_user(username='proj_u', **common)
def setUp(self):
self.client = Client()
def test_list_requires_permission(self):
login_2fa(self.client, self.user)
r = self.client.get('/projects/')
self.assertIn(r.status_code, (302, 403))
_grant_projects(self.user)
# clear perm cache
self.user = Employee.objects.get(pk=self.user.pk)
login_2fa(self.client, self.user)
r = self.client.get('/projects/')
self.assertEqual(r.status_code, 200)
+24
View File
@@ -0,0 +1,24 @@
from django.urls import path
from . import views
urlpatterns = [
path('projects/', views.projects_list_view, name='projects'),
path('projects/create/', views.project_create_view, name='project_create'),
path('projects/<int:pk>/', views.project_detail_view, name='project_detail'),
path(
'projects/<int:pk>/activate/',
views.project_activate_view,
name='project_activate',
),
path(
'projects/<int:pk>/steps/add/',
views.project_add_step_view,
name='project_add_step',
),
path(
'projects/<int:pk>/steps/<int:step_id>/activate/',
views.project_step_activate_view,
name='project_step_activate',
),
]
+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)
@@ -0,0 +1,77 @@
"""Одноразовая заливка локального MEDIA_ROOT в бакет SILO/S3.
Требует S3_ENDPOINT + ключи в .env (USE_S3_MEDIA=True).
Ключи объектов = относительные пути от MEDIA_ROOT (employees/photos/...).
python manage.py sync_media_to_s3
python manage.py sync_media_to_s3 --dry-run
python manage.py sync_media_to_s3 --skip-existing
"""
from __future__ import annotations
from pathlib import Path
from django.conf import settings
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = 'Upload files from MEDIA_ROOT to the configured S3/SILO media bucket.'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='List files that would be uploaded without writing.',
)
parser.add_argument(
'--skip-existing',
action='store_true',
help='Skip keys that already exist in the bucket.',
)
def handle(self, *args, **options):
if not getattr(settings, 'USE_S3_MEDIA', False):
raise CommandError(
'S3 media is not configured. Set S3_ENDPOINT, S3_ACCESS_KEY, '
'S3_SECRET_KEY (see docs/silo.md).'
)
root = Path(settings.MEDIA_ROOT)
if not root.is_dir():
raise CommandError(f'MEDIA_ROOT does not exist: {root}')
dry = options['dry_run']
skip_existing = options['skip_existing']
uploaded = skipped = errors = 0
for path in sorted(root.rglob('*')):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
if skip_existing and default_storage.exists(rel):
skipped += 1
self.stdout.write(f'skip {rel}')
continue
if dry:
self.stdout.write(f'would {rel}')
uploaded += 1
continue
try:
with path.open('rb') as fh:
# Avoid name collision suffix: save under exact relative key.
if default_storage.exists(rel):
default_storage.delete(rel)
saved = default_storage.save(rel, fh)
self.stdout.write(self.style.SUCCESS(f'ok {saved}'))
uploaded += 1
except Exception as exc: # noqa: BLE001
errors += 1
self.stderr.write(self.style.ERROR(f'fail {rel}: {exc}'))
self.stdout.write(
f'Done: uploaded={uploaded} skipped={skipped} errors={errors}'
+ (' (dry-run)' if dry else '')
)
if errors:
raise CommandError(f'{errors} file(s) failed to upload.')
@@ -0,0 +1,98 @@
"""Право доступа к разделу «Проекты» портала."""
from django.db import migrations
NEW_PERMS = [
('access_projects', 'Доступ к разделу: Проекты'),
]
GROUP_ADDITIONS = {
'Офисный сотрудник': ['access_projects'],
'IT': ['access_projects'],
}
def forwards(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
Permission = apps.get_model('auth', 'Permission')
ContentType = apps.get_model('contenttypes', 'ContentType')
ct, _ = ContentType.objects.get_or_create(app_label='core', model='portalaccess')
perms_by_code = {}
for codename, name in NEW_PERMS:
perm, _ = Permission.objects.get_or_create(
content_type=ct, codename=codename,
defaults={'name': name},
)
if perm.name != name:
perm.name = name
perm.save(update_fields=['name'])
perms_by_code[codename] = perm
admin_group = Group.objects.filter(name='Администратор сети').first()
if admin_group:
admin_group.permissions.add(*perms_by_code.values())
for group_name, codes in GROUP_ADDITIONS.items():
group = Group.objects.filter(name=group_name).first()
if not group:
continue
to_add = [perms_by_code[c] for c in codes if c in perms_by_code]
if to_add:
group.permissions.add(*to_add)
def backwards(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
Permission = apps.get_model('auth', 'Permission')
ContentType = apps.get_model('contenttypes', 'ContentType')
ct = ContentType.objects.filter(app_label='core', model='portalaccess').first()
if ct is None:
return
perms = list(
Permission.objects.filter(content_type=ct, codename__in=[c for c, _ in NEW_PERMS])
)
if not perms:
return
for group in Group.objects.filter(
name__in=['Администратор сети', *GROUP_ADDITIONS.keys()],
):
group.permissions.remove(*perms)
Permission.objects.filter(pk__in=[p.pk for p in perms]).delete()
class Migration(migrations.Migration):
dependencies = [
('core', '0025_internal_line_type_and_asterisk_ami'),
]
operations = [
migrations.AlterModelOptions(
name='portalaccess',
options={
'default_permissions': (),
'managed': False,
'permissions': [
('access_hr_portal', 'Доступ к разделу: HR-портал'),
('access_recruiting', 'Доступ к разделу: Рекрутинг'),
('access_structure', 'Доступ к разделу: Структура'),
('access_timetrack', 'Доступ к разделу: Учёт рабочего времени'),
('access_databases', 'Доступ к разделу: Системные справочники'),
('access_reports', 'Доступ к разделу: Отчёты'),
('access_purchases', 'Доступ к разделу: Закупки'),
('access_email', 'Доступ к разделу: Электронная почта'),
('access_pharmacies', 'Доступ к разделу: Аптеки'),
('access_rro', 'Доступ к разделу: РРО / ПРРО'),
('access_utilities', 'Доступ к разделу: Коммунальные услуги'),
('access_internet_billing', 'Доступ к разделу: Оплата провайдеров'),
('access_it_manager', 'Доступ к разделу: IT-отдел'),
('access_ad_mapping', 'Доступ к разделу: Active Directory'),
('access_projects', 'Доступ к разделу: Проекты'),
],
'verbose_name': 'Доступ к разделу портала',
'verbose_name_plural': 'Доступы к разделам портала',
},
),
migrations.RunPython(forwards, backwards),
]
+4
View File
@@ -29,6 +29,7 @@ RESTRICTED_KEYS = (
'internet_billing',
'it_manager',
'ad_mapping',
'projects',
)
# UI-заглушки (мок-страницы): в меню и по URL только для staff (доступ к /admin/).
@@ -41,6 +42,9 @@ PORTAL_SECTIONS: dict[str, PortalSection] = {
'news': PortalSection('news', '/news/', 'Новости', 'main'),
'employees': PortalSection('employees', '/employees/', 'Сотрудники', 'main'),
'tasks': PortalSection('tasks', '/tasks/', 'Задачи', 'main'),
'projects': PortalSection(
'projects', '/projects/', 'Проекты', 'main', 'core.access_projects',
),
'dislocation': PortalSection('dislocation', '/dislocation/', 'Дислокация', 'main'),
'phonebook': PortalSection('phonebook', '/telephony/', 'Телефонная книга', 'none'),
'documents': PortalSection('documents', '/documents/', 'Документы', 'main'),
+85
View File
@@ -0,0 +1,85 @@
"""S3/SILO media storage wiring (no live SILO required)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from django.core.files.base import ContentFile
from django.core.files.storage import FileSystemStorage
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import SimpleTestCase, TestCase, override_settings
class S3MediaSettingsTests(SimpleTestCase):
def test_use_s3_media_false_without_endpoint(self):
from django.conf import settings
self.assertFalse(settings.USE_S3_MEDIA)
@override_settings(
USE_S3_MEDIA=True,
STORAGES={
'default': {
'BACKEND': 'django.core.files.storage.InMemoryStorage',
},
'staticfiles': {
'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
},
},
)
def test_default_storage_uses_configured_backend_when_s3_flag_on(self):
from django.core.files.storage import default_storage
name = default_storage.save('s3-test/hello.txt', ContentFile(b'hi'))
self.assertTrue(default_storage.exists(name))
self.assertEqual(default_storage.open(name).read(), b'hi')
class SyncMediaToS3CommandTests(TestCase):
def test_requires_s3_config(self):
with self.assertRaises(CommandError) as ctx:
call_command('sync_media_to_s3')
self.assertIn('S3 media is not configured', str(ctx.exception))
@override_settings(USE_S3_MEDIA=True, MEDIA_ROOT='/tmp/acore-media-missing-xyz')
def test_missing_media_root(self):
with self.assertRaises(CommandError) as ctx:
call_command('sync_media_to_s3')
self.assertIn('MEDIA_ROOT does not exist', str(ctx.exception))
def test_dry_run_lists_files(self):
media = Path(self._media_dir())
(media / 'employees').mkdir(parents=True)
(media / 'employees' / 'a.jpg').write_bytes(b'\xff\xd8')
mock_storage = MagicMock(spec=FileSystemStorage)
mock_storage.exists.return_value = False
with override_settings(USE_S3_MEDIA=True, MEDIA_ROOT=str(media)), \
patch('core.management.commands.sync_media_to_s3.default_storage', mock_storage):
call_command('sync_media_to_s3', '--dry-run')
mock_storage.save.assert_not_called()
def test_upload_calls_storage_save(self):
media = Path(self._media_dir())
(media / 'x').mkdir(parents=True)
(media / 'x' / 'f.bin').write_bytes(b'abc')
mock_storage = MagicMock()
mock_storage.exists.return_value = False
mock_storage.save.side_effect = lambda name, content: name
with override_settings(USE_S3_MEDIA=True, MEDIA_ROOT=str(media)), \
patch('core.management.commands.sync_media_to_s3.default_storage', mock_storage):
call_command('sync_media_to_s3')
mock_storage.save.assert_called_once()
args, _kwargs = mock_storage.save.call_args
self.assertEqual(args[0], 'x/f.bin')
def _media_dir(self) -> str:
import tempfile
return tempfile.mkdtemp(prefix='acore-media-test-')
+102
View File
@@ -0,0 +1,102 @@
# SILO → ACORE media + Nextcloud
SILO ([pgsty/silo](https://silo.pigsty.io/)) — S3-совместимое хранилище (форк MinIO).
Портал **не** ходит в S3 за документами: документы остаются через Nextcloud WebDAV.
SILO — бэкенд файлов Nextcloud и Django `MEDIA` (фото и т.п.).
```
ACORE ImageField ──▶ bucket acore-media (SILO)
ACORE Documents ──▶ Nextcloud WebDAV ──▶ bucket nextcloud (SILO)
```
## 0. SILO: бакеты и ключи
На машине с доступом к API SILO (`:9000`):
```bash
export SILO_ENDPOINT=https://s3.example.local:9000
export SILO_ROOT_USER=...
export SILO_ROOT_PASSWORD=...
chmod +x scripts/silo_bootstrap.sh
./scripts/silo_bootstrap.sh
```
Скрипт создаёт:
| Bucket | Пользователь | Назначение |
|--------|--------------|------------|
| `acore-media` | `acore-media-rw` | Django media |
| `nextcloud` | `nextcloud-rw` | Primary object store NC |
Включает versioning и smoke put/get. Root-ключ после bootstrap для приложений не использовать.
Проверка с хостов ACORE и Nextcloud:
```bash
mcli alias set check "$SILO_ENDPOINT" acore-media-rw "$S3_SECRET_KEY"
mcli ls check/acore-media
```
TLS: лучше reverse-proxy (Traefik/nginx) на API; клиентам — `https://…`.
## 1. Nextcloud: primary object store
1. Остановить NC, бэкап data-dir (`config/`, `data/`).
2. В `config/config.php` добавить **до** первого запуска с новым store (или через occ, если версия поддерживает):
```php
'objectstore' => [
'class' => '\\OC\\Files\\ObjectStore\\S3',
'arguments' => [
'bucket' => 'nextcloud',
'autocreate' => false,
'key' => 'nextcloud-rw',
'secret' => 'PASTE_SECRET',
'hostname' => 's3.example.local',
'port' => 9000,
'use_ssl' => true,
'use_path_style' => true,
'region' => 'us-east-1',
// при необходимости: 'legacy_auth' => true,
],
],
```
`hostname` — хост **без** схемы; порт отдельно. Path-style обязателен для SILO/MinIO.
3. Если на локальном диске уже есть файлы пользователей — миграция отдельным окном (occ files:scan / инструменты вендора, либо чистый стенд). Не монтируйте тот же бакет как External Storage *и* primary одновременно для одних и тех же данных.
4. Запуск NC → upload в UI → с портала browse/upload документов и документов аптеки (WebDAV без изменений в ACORE).
5. Логи: `nextcloud.log`, ошибки `objectstore` / S3.
**External Storage** (админка → Внешнее хранилище) — только если нужна *дополнительная* шара рядом с локальным диском. Для «все документы портала в SILO» нужен именно **primary object store**, как выше.
## 2. ACORE media
В `.env` (секреты из вывода bootstrap):
```env
S3_ENDPOINT=https://s3.example.local:9000
S3_ACCESS_KEY=acore-media-rw
S3_SECRET_KEY=...
S3_MEDIA_BUCKET=acore-media
S3_REGION=us-east-1
# S3_QUERYSTRING_EXPIRE=3600
```
Если `S3_ENDPOINT` + ключи заданы — `STORAGES['default']` = S3; иначе локальный `MEDIA_ROOT` (dev).
Одноразовая заливка существующего диска:
```bash
python manage.py sync_media_to_s3
# или dry-run:
python manage.py sync_media_to_s3 --dry-run
```
После стабилизации nginx **не** должен раздавать `/media/` с диска приложения: URL файлов — подписанные ссылки на SILO. Static по-прежнему с диска/`collectstatic`.
## Эксплуатация
- Бэкап бакетов SILO отдельно от PostgreSQL (versioning ≠ бэкап).
- Разные ключи на media и Nextcloud; не коммитить секреты.
- После cutover локальный `media/` можно оставить read-only архивом.
+6 -2
View File
@@ -15,8 +15,9 @@
- **Traefik** терминирует TLS и проксирует на LXC по HTTP с заголовком `X-Forwarded-Proto: https`
(`production.py` уже настроен на `SECURE_PROXY_SSL_HEADER`).
- **nginx внутри LXC** отдаёт `/static/` и `/media/`, остальное проксирует в gunicorn.
- **nginx внутри LXC** отдаёт `/static/` (и `/media/` только пока media на локальном диске).
- **PostgreSQL** и **Redis** — отдельные сервисы (могут быть на других хостах).
- **SILO (S3)** — опционально: Django media + primary store Nextcloud; см. [silo.md](silo.md).
---
@@ -252,6 +253,7 @@ server {
client_max_body_size 110m; # аплоады до 100 МБ (документы)
location /static/ { alias /opt/acore/app/staticfiles/; access_log off; expires 30d; }
# При SILO (S3_ENDPOINT в .env) /media/ с диска не нужен — URL подписанные.
location /media/ { alias /opt/acore/app/media/; access_log off; }
location / {
@@ -284,6 +286,8 @@ TLS-termination + редирект на HTTPS. Убедитесь, что Traefi
- **Mattermost** — bot-токен + `MATTERMOST_SLASH_COMMAND_TOKEN`/`INTERACTIVE_TOKEN` в `.env`.
Команды: `create_mattermost_bot`, `import_mattermost_teams/channels`, `sync_users_to_teams`.
- **Nextcloud** — `ConnectorConfig(nextcloud)`: host/логин/пароль сервисного аккаунта (WebDAV).
Файлы NC лучше держать в SILO (primary object store) — [silo.md](silo.md).
- **SILO / media** — при `S3_ENDPOINT` + ключах в `.env` фото и прочий `MEDIA` пишутся в бакет `acore-media`; nginx `/media/` с диска не нужен.
- **OnlyOffice** (`ConnectorConfig(onlyoffice)`):
- `host/port/use_ssl` → публичный URL Document Server (браузер грузит `api.js`);
- **«Токен»** = `JWT_SECRET` Document Server (`JWT_ENABLED=true`, `JWT_HEADER=Authorization`);
@@ -308,7 +312,7 @@ cd /opt/acore/app
```
- [ ] `https://portal.example.local/` открывается, редирект c http на https работает;
- [ ] логин, принудительная 2FA у staff;
- [ ] `/static/` и `/media/` отдаёт nginx (не 404);
- [ ] `/static/` отдаёт nginx; `/media/` — nginx **или** подписанные URL SILO (см. [silo.md](silo.md));
- [ ] `systemctl status acore-web acore-celery acore-celery-beat` — все `active (running)`;
- [ ] `redis-cli -a … -n 1 dbsize` растёт при попытках логина (кэш-троттлинг жив);
- [ ] загрузка документа в аптеке и открытие в OnlyOffice → правка сохраняется в Nextcloud.
+2
View File
@@ -20,10 +20,12 @@ defusedxml==0.7.1
diff-match-patch==20241021
django-celery-beat==2.9.0
django-celery-results==2.5.1
boto3==1.43.114
django-environ==0.13.0
django-fast-treenode==3.2.10
django-import-export==4.4.0
django-location-field==2.7.3
django-storages==1.14.6
django-timezone-field==7.2.1
django-tinymce==5.0.0
django-unfold==0.87.0
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Bootstrap SILO (MinIO-compatible): buckets + scoped access keys for ACORE / Nextcloud.
#
# Prerequisites: mcli (or mc) client, SILO API reachable, openssl.
# Usage:
# export SILO_ENDPOINT=https://s3.example.local:9000
# export SILO_ROOT_USER=...
# export SILO_ROOT_PASSWORD=...
# ./scripts/silo_bootstrap.sh
#
# Optional: ACORE_MEDIA_BUCKET, NEXTCLOUD_BUCKET, ALIAS
set -euo pipefail
ENDPOINT="${SILO_ENDPOINT:?Set SILO_ENDPOINT (e.g. https://s3.example.local:9000)}"
ROOT_USER="${SILO_ROOT_USER:?Set SILO_ROOT_USER}"
ROOT_PASSWORD="${SILO_ROOT_PASSWORD:?Set SILO_ROOT_PASSWORD}"
ACORE_MEDIA_BUCKET="${ACORE_MEDIA_BUCKET:-acore-media}"
NEXTCLOUD_BUCKET="${NEXTCLOUD_BUCKET:-nextcloud}"
ALIAS="${ALIAS:-acore-silo}"
if command -v mcli >/dev/null 2>&1; then
MC=mcli
elif command -v mc >/dev/null 2>&1; then
MC=mc
else
echo "Need mcli or mc in PATH" >&2
exit 1
fi
echo "==> Alias ${ALIAS} → ${ENDPOINT}"
"${MC}" alias set "${ALIAS}" "${ENDPOINT}" "${ROOT_USER}" "${ROOT_PASSWORD}"
echo "==> Buckets"
"${MC}" mb --ignore-existing "${ALIAS}/${ACORE_MEDIA_BUCKET}"
"${MC}" mb --ignore-existing "${ALIAS}/${NEXTCLOUD_BUCKET}"
echo "==> Versioning"
"${MC}" version enable "${ALIAS}/${ACORE_MEDIA_BUCKET}" || true
"${MC}" version enable "${ALIAS}/${NEXTCLOUD_BUCKET}" || true
POLICY_DIR="$(mktemp -d)"
trap 'rm -rf "${POLICY_DIR}"' EXIT
cat >"${POLICY_DIR}/acore-media-rw.json" <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
"arn:aws:s3:::${ACORE_MEDIA_BUCKET}",
"arn:aws:s3:::${ACORE_MEDIA_BUCKET}/*"
]
}
]
}
EOF
cat >"${POLICY_DIR}/nextcloud-rw.json" <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
"arn:aws:s3:::${NEXTCLOUD_BUCKET}",
"arn:aws:s3:::${NEXTCLOUD_BUCKET}/*"
]
}
]
}
EOF
_policy_upsert() {
local name="$1" file="$2"
if "${MC}" admin policy create "${ALIAS}" "${name}" "${file}" 2>/dev/null; then
return 0
fi
if "${MC}" admin policy add "${ALIAS}" "${name}" "${file}" 2>/dev/null; then
return 0
fi
"${MC}" admin policy set "${ALIAS}" "${name}" "${file}"
}
echo "==> Policies"
_policy_upsert acore-media-rw "${POLICY_DIR}/acore-media-rw.json"
_policy_upsert nextcloud-rw "${POLICY_DIR}/nextcloud-rw.json"
ACORE_PASS="$(openssl rand -hex 24)"
NC_PASS="$(openssl rand -hex 24)"
_user_ensure() {
local user="$1" pass="$2" policy="$3"
if ! "${MC}" admin user info "${ALIAS}" "${user}" >/dev/null 2>&1; then
"${MC}" admin user add "${ALIAS}" "${user}" "${pass}"
else
"${MC}" admin user svcacct add "${ALIAS}" "${user}" 2>/dev/null || true
echo "User ${user} already exists — set password manually if needed:" >&2
echo " ${MC} admin user add ${ALIAS} ${user} <new-secret>" >&2
fi
if "${MC}" admin policy attach "${ALIAS}" "${policy}" --user "${user}" 2>/dev/null; then
return 0
fi
"${MC}" admin policy set "${ALIAS}" "${policy}" "user=${user}" 2>/dev/null || true
}
echo "==> Users"
_user_ensure acore-media-rw "${ACORE_PASS}" acore-media-rw
_user_ensure nextcloud-rw "${NC_PASS}" nextcloud-rw
echo "==> Smoke put/get (acore-media)"
SMOKE_KEY="silo-bootstrap-smoke-$(date +%s).txt"
echo "ok $(date -Iseconds)" | "${MC}" pipe "${ALIAS}/${ACORE_MEDIA_BUCKET}/${SMOKE_KEY}"
"${MC}" cat "${ALIAS}/${ACORE_MEDIA_BUCKET}/${SMOKE_KEY}" >/dev/null
"${MC}" rm --force "${ALIAS}/${ACORE_MEDIA_BUCKET}/${SMOKE_KEY}"
echo
echo "=== ACORE (.env) ==="
echo "S3_ENDPOINT=${ENDPOINT}"
echo "S3_ACCESS_KEY=acore-media-rw"
echo "S3_SECRET_KEY=${ACORE_PASS}"
echo "S3_MEDIA_BUCKET=${ACORE_MEDIA_BUCKET}"
echo "S3_REGION=us-east-1"
echo
echo "=== Nextcloud object store ==="
echo "key = nextcloud-rw"
echo "secret = ${NC_PASS}"
echo "bucket = ${NEXTCLOUD_BUCKET}"
echo "endpoint = ${ENDPOINT}"
echo
echo "Done. Store secrets offline; do not commit them."
+10
View File
@@ -279,6 +279,16 @@
<span class="nav-label-text">{% trans "Задачи" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'projects' %}
<a href="/projects/" class="nav-item"
hx-get="/projects/" hx-target="#main-content" hx-push-url="/projects/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 20h20"/><path d="M5 20V8l5 4V8l5 4V4l5 4v12"/>
</svg>
<span class="nav-label-text">{% trans "Проекты" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'dislocation' %}
<a href="/dislocation/" class="nav-item"
hx-get="/dislocation/" hx-target="#main-content" hx-push-url="/dislocation/"
+109
View File
@@ -0,0 +1,109 @@
{% load i18n %}
<div class="page-content">
<div style="margin-bottom:1rem;">
<a href="{% url 'projects' %}" hx-get="{% url 'projects' %}" hx-target="#main-content" hx-push-url="{% url 'projects' %}" hx-indicator="#loader"
style="font-size:0.8125rem; color:var(--color-primary); text-decoration:none;">← {% trans "К списку проектов" %}</a>
</div>
{% if ug_message %}
<div style="margin-bottom:1rem; padding:0.625rem 0.875rem; border-radius:0.5rem; font-size:0.8125rem; color:#137333; background:rgba(19,115,51,0.1); border:1px solid rgba(19,115,51,0.25);">{{ ug_message }}</div>
{% endif %}
{% if ug_error %}
<div style="margin-bottom:1rem; padding:0.625rem 0.875rem; border-radius:0.5rem; font-size:0.8125rem; color:#d63333; background:rgba(214,51,51,0.1); border:1px solid rgba(214,51,51,0.25);">{{ ug_error }}</div>
{% endif %}
<div style="display:flex; justify-content:space-between; gap:1rem; flex-wrap:wrap; margin-bottom:1.25rem; align-items:flex-start;">
<div>
<h1 style="font-size:1.25rem; font-weight:500; margin:0;">{{ project.title }}</h1>
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin:0.35rem 0 0;">
{{ project.get_status_display }}
{% if project.template %} · {{ project.template.name }}{% endif %}
{% if project.pharmacy %} · {{ project.pharmacy }}{% endif %}
{% if project.start_date %} · {% trans "с" %} {{ project.start_date }}{% endif %}
{% if project.due_date %} · {% trans "до" %} {{ project.due_date }}{% endif %}
</p>
{% if project.description %}
<p style="font-size:0.875rem; margin:0.75rem 0 0; max-width:40rem;">{{ project.description }}</p>
{% endif %}
</div>
{% if can_manage and project.status == 'draft' %}
<form method="post" action="{% url 'project_activate' project.pk %}" style="margin:0;">
{% csrf_token %}
<button type="submit" class="btn btn-primary">{% trans "Запустить проект" %}</button>
</form>
{% endif %}
</div>
<div style="margin-bottom:1.5rem; max-width:28rem;">
<div style="display:flex; justify-content:space-between; font-size:0.75rem; color:var(--color-muted-foreground); margin-bottom:0.35rem;">
<span>{% trans "Прогресс" %}</span>
<span>{{ done_count }}/{{ total_count }} · {{ pct }}%</span>
</div>
<div style="height:0.45rem; background:var(--color-muted); border-radius:999px; overflow:hidden;">
<div style="height:100%; width:{{ pct }}%; background:var(--color-primary);"></div>
</div>
</div>
<h2 style="font-size:0.9375rem; font-weight:500; margin:0 0 0.75rem;">{% trans "Шаги" %}</h2>
{% for step in project.steps.all %}
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1rem 1.15rem; margin-bottom:0.75rem;">
<div style="display:flex; justify-content:space-between; gap:0.75rem; flex-wrap:wrap; align-items:flex-start;">
<div>
<div style="font-size:0.9375rem; font-weight:500;">{{ step.order|add:1 }}. {{ step.title }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.2rem;">
{% if step.department %}{{ step.department.name }} · {% endif %}
{{ step.get_status_display }}
{% if step.planned_due %} · {% trans "до" %} {{ step.planned_due }}{% endif %}
</div>
</div>
{% if can_manage and project.status != 'cancelled' %}
{% if step.items.all %}
<form method="post" action="{% url 'project_step_activate' project.pk step.pk %}" style="margin:0;">
{% csrf_token %}
<button type="submit" class="btn btn-secondary" style="font-size:0.75rem;">{% trans "Создать задачи шага" %}</button>
</form>
{% endif %}
{% endif %}
</div>
<ul style="list-style:none; margin:0.75rem 0 0; padding:0;">
{% for item in step.items.all %}
<li style="display:flex; align-items:center; gap:0.5rem; padding:0.4rem 0; border-top:1px solid var(--color-border); font-size:0.8125rem;">
{% if item.task %}
<a href="{% url 'task_detail' item.task.pk %}"
hx-get="{% url 'task_detail' item.task.pk %}" hx-target="#main-content" hx-push-url="{% url 'task_detail' item.task.pk %}" hx-indicator="#loader"
style="color:var(--color-primary); text-decoration:none; flex:1;">
{{ item.title }}
<span style="color:var(--color-muted-foreground); font-size:0.75rem;"> · {{ item.task.get_status_display }}</span>
</a>
{% else %}
<span style="flex:1; color:var(--color-muted-foreground);">{{ item.title }} <em>({% trans "задача ещё не создана" %})</em></span>
{% endif %}
</li>
{% empty %}
<li style="font-size:0.8125rem; color:var(--color-muted-foreground); padding-top:0.5rem;">{% trans "Нет пунктов" %}</li>
{% endfor %}
</ul>
</div>
{% empty %}
<p style="color:var(--color-muted-foreground); font-size:0.875rem;">{% trans "Шагов пока нет." %}</p>
{% endfor %}
{% if can_manage and project.status != 'cancelled' and project.status != 'done' %}
<div style="margin-top:1.5rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.15rem;">
<h3 style="font-size:0.875rem; font-weight:500; margin:0 0 0.75rem;">{% trans "Добавить шаг" %}</h3>
<form method="post" action="{% url 'project_add_step' project.pk %}">
{% csrf_token %}
<input type="text" name="title" required class="input" style="width:100%; margin-bottom:0.5rem;" placeholder="{% trans 'Название шага' %}">
<select name="department_id" class="input" style="width:100%; margin-bottom:0.5rem;">
<option value="">{% trans "Отдел…" %}</option>
{% for d in departments %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<textarea name="items" class="input" rows="3" style="width:100%; margin-bottom:0.75rem;" placeholder="{% trans 'Задачи, каждая с новой строки' %}"></textarea>
<button type="submit" class="btn btn-primary">{% trans "Добавить" %}</button>
</form>
</div>
{% endif %}
</div>
+127
View File
@@ -0,0 +1,127 @@
{% load i18n %}
<div class="page-content">
<div style="display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:1.25rem; gap:0.75rem; flex-wrap:wrap;">
<div>
<h1 style="font-size:1.25rem; font-weight:500; margin:0;">{% trans "Проекты" %}</h1>
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin:0.25rem 0 0;">
{% trans "Межотдельские проекты со шагами и задачами (например, открытие аптеки)" %}
</p>
</div>
<button type="button" class="btn btn-primary" onclick="document.getElementById('project-create').showModal()">
{% trans "Новый проект" %}
</button>
</div>
{% if ug_message %}
<div style="margin-bottom:1rem; padding:0.625rem 0.875rem; border-radius:0.5rem; font-size:0.8125rem; color:#137333; background:rgba(19,115,51,0.1); border:1px solid rgba(19,115,51,0.25);">{{ ug_message }}</div>
{% endif %}
{% if ug_error %}
<div style="margin-bottom:1rem; padding:0.625rem 0.875rem; border-radius:0.5rem; font-size:0.8125rem; color:#d63333; background:rgba(214,51,51,0.1); border:1px solid rgba(214,51,51,0.25);">{{ ug_error }}</div>
{% endif %}
<div style="display:flex; gap:0.25rem; margin-bottom:1rem; background:var(--color-muted); border-radius:var(--radius); padding:0.25rem; width:fit-content; flex-wrap:wrap;">
{% with base="display:inline-flex; padding:0.375rem 0.75rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; text-decoration:none;" %}
<a href="{% url 'projects' %}" hx-get="{% url 'projects' %}" hx-target="#main-content" hx-push-url="{% url 'projects' %}" hx-indicator="#loader"
style="{{ base }} {% if not active_status %}background:var(--color-primary); color:var(--color-primary-foreground);{% else %}color:var(--color-muted-foreground);{% endif %}">{% trans "Все" %}</a>
<a href="{% url 'projects' %}?status=draft" hx-get="{% url 'projects' %}?status=draft" hx-target="#main-content" hx-push-url="{% url 'projects' %}?status=draft" hx-indicator="#loader"
style="{{ base }} {% if active_status == 'draft' %}background:var(--color-primary); color:var(--color-primary-foreground);{% else %}color:var(--color-muted-foreground);{% endif %}">{% trans "Черновики" %}</a>
<a href="{% url 'projects' %}?status=active" hx-get="{% url 'projects' %}?status=active" hx-target="#main-content" hx-push-url="{% url 'projects' %}?status=active" hx-indicator="#loader"
style="{{ base }} {% if active_status == 'active' %}background:var(--color-primary); color:var(--color-primary-foreground);{% else %}color:var(--color-muted-foreground);{% endif %}">{% trans "Активные" %}</a>
<a href="{% url 'projects' %}?status=done" hx-get="{% url 'projects' %}?status=done" hx-target="#main-content" hx-push-url="{% url 'projects' %}?status=done" hx-indicator="#loader"
style="{{ base }} {% if active_status == 'done' %}background:var(--color-primary); color:var(--color-primary-foreground);{% else %}color:var(--color-muted-foreground);{% endif %}">{% trans "Завершённые" %}</a>
{% endwith %}
</div>
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); overflow:hidden;">
{% for p in projects %}
<a href="{% url 'project_detail' p.pk %}"
hx-get="{% url 'project_detail' p.pk %}" hx-target="#main-content" hx-push-url="{% url 'project_detail' p.pk %}" hx-indicator="#loader"
class="li-hover" style="display:block; padding:0.875rem 1rem; text-decoration:none; color:inherit; {% if not forloop.last %}border-bottom:1px solid var(--color-border);{% endif %}">
<div style="display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; flex-wrap:wrap;">
<div style="min-width:0; flex:1;">
<div style="font-size:0.9375rem; font-weight:500;">{{ p.title }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.2rem;">
{% if p.pharmacy %}{{ p.pharmacy }} · {% endif %}
{% if p.owner %}{{ p.owner.get_full_name }} · {% endif %}
{{ p.get_status_display }}
{% if p.due_date %} · {% trans "до" %} {{ p.due_date }}{% endif %}
</div>
</div>
<div style="min-width:8rem;">
<div style="font-size:0.7rem; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{{ p.done_count }}/{{ p.total_count }} · {{ p.pct }}%</div>
<div style="height:0.35rem; background:var(--color-muted); border-radius:999px; overflow:hidden;">
<div style="height:100%; width:{{ p.pct }}%; background:var(--color-primary);"></div>
</div>
</div>
</div>
</a>
{% empty %}
<div style="padding:2rem; text-align:center; color:var(--color-muted-foreground); font-size:0.875rem;">
{% trans "Проектов пока нет. Создайте из шаблона или вручную." %}
</div>
{% endfor %}
</div>
</div>
<dialog id="project-create" style="border:1px solid var(--color-border); border-radius:0.75rem; padding:0; max-width:32rem; width:calc(100% - 2rem); background:var(--color-card); color:var(--color-foreground);">
<form method="post" action="{% url 'project_create' %}" style="padding:1.25rem; margin:0;">
{% csrf_token %}
<h2 style="font-size:1rem; font-weight:500; margin:0 0 1rem;">{% trans "Новый проект" %}</h2>
<label style="display:block; font-size:0.75rem; margin-bottom:0.35rem;">{% trans "Режим" %}</label>
<select name="mode" id="project-mode" class="input" style="width:100%; margin-bottom:0.75rem;" onchange="document.getElementById('tpl-block').style.display=this.value==='template'?'block':'none'; document.getElementById('manual-block').style.display=this.value==='manual'?'block':'none';">
<option value="template">{% trans "Из шаблона" %}</option>
<option value="manual">{% trans "Вручную" %}</option>
</select>
<label style="display:block; font-size:0.75rem; margin-bottom:0.35rem;">{% trans "Название" %}</label>
<input type="text" name="title" class="input" style="width:100%; margin-bottom:0.75rem;" placeholder="{% trans 'Открытие аптеки №…' %}">
<div id="tpl-block">
<label style="display:block; font-size:0.75rem; margin-bottom:0.35rem;">{% trans "Шаблон" %}</label>
<select name="template_id" class="input" style="width:100%; margin-bottom:0.75rem;">
<option value="">{% trans "Выберите…" %}</option>
{% for t in templates %}
<option value="{{ t.id }}">{{ t.name }} ({{ t.default_duration_days }} {% trans "дн." %})</option>
{% endfor %}
</select>
</div>
<div id="manual-block" style="display:none;">
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0 0 0.5rem;">
{% trans "Шаги: название, отдел, задачи (по одной на строку). Можно добавить больше шагов на карточке." %}
</p>
{% for i in "12" %}
<div style="border:1px solid var(--color-border); border-radius:0.5rem; padding:0.75rem; margin-bottom:0.5rem;">
<input type="text" name="step_title" class="input" style="width:100%; margin-bottom:0.35rem;" placeholder="{% trans 'Шаг' %} {{ forloop.counter }}">
<select name="step_department_id" class="input" style="width:100%; margin-bottom:0.35rem;">
<option value="">{% trans "Отдел…" %}</option>
{% for d in departments %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<textarea name="step_items" class="input" rows="2" style="width:100%;" placeholder="{% trans 'Задачи, каждая с новой строки' %}"></textarea>
</div>
{% endfor %}
</div>
<label style="display:block; font-size:0.75rem; margin-bottom:0.35rem;">{% trans "Описание" %}</label>
<textarea name="description" class="input" rows="2" style="width:100%; margin-bottom:0.75rem;"></textarea>
<div style="display:flex; gap:0.5rem; margin-bottom:1rem; flex-wrap:wrap;">
<div style="flex:1; min-width:8rem;">
<label style="display:block; font-size:0.75rem; margin-bottom:0.35rem;">{% trans "Старт" %}</label>
<input type="date" name="start_date" class="input" style="width:100%;">
</div>
<div style="flex:1; min-width:8rem;">
<label style="display:block; font-size:0.75rem; margin-bottom:0.35rem;">{% trans "Срок" %}</label>
<input type="date" name="due_date" class="input" style="width:100%;">
</div>
</div>
<div style="display:flex; gap:0.5rem; justify-content:flex-end;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">{% trans "Отмена" %}</button>
<button type="submit" class="btn btn-primary">{% trans "Создать" %}</button>
</div>
</form>
</dialog>