diff --git a/.env.example b/.env.example index 93ef11b..563426e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/acore/settings/base.py b/acore/settings/base.py index 2f4aaab..405b661 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -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"), + }, ], }, { diff --git a/acore/urls.py b/acore/urls.py index 0cee6ab..3abdd5c 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -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) diff --git a/apps/projects/__init__.py b/apps/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/admin.py b/apps/projects/admin.py new file mode 100644 index 0000000..2723d1c --- /dev/null +++ b/apps/projects/admin.py @@ -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] diff --git a/apps/projects/apps.py b/apps/projects/apps.py new file mode 100644 index 0000000..fff2226 --- /dev/null +++ b/apps/projects/apps.py @@ -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 diff --git a/apps/projects/management/__init__.py b/apps/projects/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/management/commands/__init__.py b/apps/projects/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/management/commands/seed_project_templates.py b/apps/projects/management/commands/seed_project_templates.py new file mode 100644 index 0000000..464abab --- /dev/null +++ b/apps/projects/management/commands/seed_project_templates.py @@ -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.' + )) diff --git a/apps/projects/migrations/0001_initial.py b/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..68776c3 --- /dev/null +++ b/apps/projects/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/apps/projects/migrations/__init__.py b/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/models.py b/apps/projects/models.py new file mode 100644 index 0000000..c8b963d --- /dev/null +++ b/apps/projects/models.py @@ -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 diff --git a/apps/projects/services.py b/apps/projects/services.py new file mode 100644 index 0000000..fdece59 --- /dev/null +++ b/apps/projects/services.py @@ -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 diff --git a/apps/projects/signals.py b/apps/projects/signals.py new file mode 100644 index 0000000..855dd94 --- /dev/null +++ b/apps/projects/signals.py @@ -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) diff --git a/apps/projects/tests.py b/apps/projects/tests.py new file mode 100644 index 0000000..eda5460 --- /dev/null +++ b/apps/projects/tests.py @@ -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) diff --git a/apps/projects/urls.py b/apps/projects/urls.py new file mode 100644 index 0000000..fb0d1f3 --- /dev/null +++ b/apps/projects/urls.py @@ -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//', views.project_detail_view, name='project_detail'), + path( + 'projects//activate/', + views.project_activate_view, + name='project_activate', + ), + path( + 'projects//steps/add/', + views.project_add_step_view, + name='project_add_step', + ), + path( + 'projects//steps//activate/', + views.project_step_activate_view, + name='project_step_activate', + ), +] diff --git a/apps/projects/views.py b/apps/projects/views.py new file mode 100644 index 0000000..d1ed0bc --- /dev/null +++ b/apps/projects/views.py @@ -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) diff --git a/core/management/commands/sync_media_to_s3.py b/core/management/commands/sync_media_to_s3.py new file mode 100644 index 0000000..460e255 --- /dev/null +++ b/core/management/commands/sync_media_to_s3.py @@ -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.') diff --git a/core/migrations/0026_access_projects_perm.py b/core/migrations/0026_access_projects_perm.py new file mode 100644 index 0000000..b180f75 --- /dev/null +++ b/core/migrations/0026_access_projects_perm.py @@ -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), + ] diff --git a/core/portal_sections.py b/core/portal_sections.py index a35ba14..b5bb2fd 100644 --- a/core/portal_sections.py +++ b/core/portal_sections.py @@ -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'), diff --git a/core/tests_s3_media.py b/core/tests_s3_media.py new file mode 100644 index 0000000..78cb34f --- /dev/null +++ b/core/tests_s3_media.py @@ -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-') diff --git a/docs/silo.md b/docs/silo.md new file mode 100644 index 0000000..d53e28d --- /dev/null +++ b/docs/silo.md @@ -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 архивом. diff --git a/docs/развёртывание-прод.md b/docs/развёртывание-прод.md index 462a55a..c0988f7 100644 --- a/docs/развёртывание-прод.md +++ b/docs/развёртывание-прод.md @@ -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. diff --git a/requirements.txt b/requirements.txt index da03370..bf7c8d5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/scripts/silo_bootstrap.sh b/scripts/silo_bootstrap.sh new file mode 100755 index 0000000..1d9478f --- /dev/null +++ b/scripts/silo_bootstrap.sh @@ -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" <"${POLICY_DIR}/nextcloud-rw.json" </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} " >&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." diff --git a/templates/index.html b/templates/index.html index b327b07..e92a3e2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -279,6 +279,16 @@ {% trans "Задачи" %} {% endif %} + {% if request.user|can_section:'projects' %} + + + + + {% trans "Проекты" %} + + {% endif %} {% if request.user|can_section:'dislocation' %} + + + {% if ug_message %} +
{{ ug_message }}
+ {% endif %} + {% if ug_error %} +
{{ ug_error }}
+ {% endif %} + +
+
+

{{ project.title }}

+

+ {{ 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 %} +

+ {% if project.description %} +

{{ project.description }}

+ {% endif %} +
+ {% if can_manage and project.status == 'draft' %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ +
+
+ {% trans "Прогресс" %} + {{ done_count }}/{{ total_count }} · {{ pct }}% +
+
+
+
+
+ +

{% trans "Шаги" %}

+ + {% for step in project.steps.all %} +
+
+
+
{{ step.order|add:1 }}. {{ step.title }}
+
+ {% if step.department %}{{ step.department.name }} · {% endif %} + {{ step.get_status_display }} + {% if step.planned_due %} · {% trans "до" %} {{ step.planned_due }}{% endif %} +
+
+ {% if can_manage and project.status != 'cancelled' %} + {% if step.items.all %} +
+ {% csrf_token %} + +
+ {% endif %} + {% endif %} +
+ +
+ {% empty %} +

{% trans "Шагов пока нет." %}

+ {% endfor %} + + {% if can_manage and project.status != 'cancelled' and project.status != 'done' %} +
+

{% trans "Добавить шаг" %}

+
+ {% csrf_token %} + + + + +
+
+ {% endif %} + diff --git a/templates/pages/projects.html b/templates/pages/projects.html new file mode 100644 index 0000000..0e0700a --- /dev/null +++ b/templates/pages/projects.html @@ -0,0 +1,127 @@ +{% load i18n %} +
+
+
+

{% trans "Проекты" %}

+

+ {% trans "Межотдельские проекты со шагами и задачами (например, открытие аптеки)" %} +

+
+ +
+ + {% if ug_message %} +
{{ ug_message }}
+ {% endif %} + {% if ug_error %} +
{{ ug_error }}
+ {% endif %} + +
+ {% 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;" %} + {% trans "Все" %} + {% trans "Черновики" %} + {% trans "Активные" %} + {% trans "Завершённые" %} + {% endwith %} +
+ + +
+ + +
+ {% csrf_token %} +

{% trans "Новый проект" %}

+ + + + + + + +
+ + +
+ + + + + + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+