projects: модуль проектов с шагами по отделам и Task.
Шаблоны/ручное создание, access_projects, сид «Открытие аптеки». Плюс SILO/S3 для Django media. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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.'
|
||||
))
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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 архивом.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+133
@@ -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."
|
||||
@@ -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/"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user