Управлять правами на уровне объектов

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Рано или поздно, разработчик на Django встречается с проблемой: как сделать так, чтобы пользователи не могли изменять или удалять, а то и вовсе не видели разных объектов одного и того же типа.


Допустим, ваш проект касается хранения информации о проектах. Разные пользователи входят в разные проекты и не должны видеть информацию о другом проекте. Один и тот же пользователь может входить в несколько проектов и иметь разный статус в разных проектах — где-то он может только просматривать информацию, а в других — править данные. В каком-то проекте пользователь зарегистрирован как персонал проекта, а в другом — только как потребитель его услуг. Уровень доступа соответственно, должен быть совершенно разным.


Этими вопросами занимаются несколько пакетов, мы рассмотрим один из них — Django-Access. Все, кому это интересно, приглашаются под кат.


Пример


Рассмотрим пример, кратко описанный в преамбуле. Будем считать, что у нас имеется некая простая система управления данными проектов определенного направления, например транспортных. С точки зрения доступа к различным данным, пользователи системы разделены на несколько категорий:


  • Администратор — может манипулировать данными об отношении персонала к проекту, которым управляет, подключать и отключать пользователей от проекта, изменять статус доступа в проекте
  • Персонал — может управлять данными всего проекта: создавать, удалять, изменять — все данные, кроме данных об отношении персонала к проекту
  • Аудитор — может просматривать данные по проекту, но не менять их
  • Клиент — может просматривать только записи, касающиеся взаимодействия лично с ним, а также менять личные данные в своем профиле

Порядок взаимодействия с системой прав Django


Для простоты, мы полностью проигнорируем встроенную систему управления правами Django, но оставим нетронутой систему данных пакета django.contrib.auth. Соответственно, мы не будем обращать внимания на объекты моделей Permission и Group и развернем собственную, альтернативную подсистему управления правами, основанную на отношении пользователей к проекту и отдельным категориям данных внутри него.


Начало


Создадим каталог для проекта и в нем — новый проект Django командой django-admin startproject sample.


Небольшие поправки в стандартный проект

Мне не нравится система именования каталогов в стандартном шаблоне Django, поэтому я переименую подкаталог sample внутри каталога проекта sample в каталог options и поменяю соответственным образом все сгенерированные внутри файлов проекта ссылки на это имя.


Создадим заготовку джанго-приложения trans командой python manage.py startapp trans и все модели нашего приложения будем создавать в нем.


Структура данных проекта


Информация обо всех проектах в системе будет содержаться в объектах модели Project.


Код
class Project(models.Model):
    name = models.CharField(
        max_length=256,
        db_index=True,
        verbose_name=_('Name'),
        help_text=_('Name of the project')
    )
    def __str__(self):
        return self.name
    class Meta:
        verbose_name = _('Project')
        verbose_name_plural = _('Projects')

Пользователь системы может иметь разный статус по отношению к разным проектам. Мы отразим это в модели ProjectMember, которая будет устанавливать связь между пользователем и проектом и хранить детали порядка доступа этого пользователя к этому проекту.


Код
class ProjectMember(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
        verbose_name=_('User'), help_text=_('User who belongs to this project'),
        related_name='projects',
    )
    project = models.ForeignKey(
        Project, on_delete=models.CASCADE,
        verbose_name=_('Project'), help_text=_('Project to which this user belongs'),
        related_name='users',
    )
    allow_change = models.BooleanField(
        default=True,
        verbose_name=_('Allow Change'),
        help_text=_('Is the member allowed to change the project data?')
    )
    allow_manage = models.BooleanField(
        default=False,
        verbose_name=_('Allow Manage'),
        help_text=_('Is the member allowed to manage users for the project?')
    )

    def __str__(self):
        return _('%s -> %s') % (self.user, self.project)

    class Meta:
        verbose_name = _('Project Member')
        verbose_name_plural = _('Project Members')
        unique_together = (
            ('user', 'project'),
        )

Внимательный читатель уже заметил, что в данных о членстве в проектах отсутствует информация о том, является ли пользователь потребителем услуг этого проекта. Будем считать, что все пользователи системы являются потенциальными потребителями услуг всех проектов, определяя факт использования услуг по наличию ссылок на этого пользователя внутри данных самого проекта. Таким образом, наличие информации о членстве определяет, имеет ли пользователь какое-либо отношение к проекту, помимо факта получения услуг от этого проекта.


Примером данных проекта — тех, которыми управляет персонал проекта — будут следующие модели:


  • Автомобили Vehicle
  • Водители Driver
  • Заказы Order

Для всех из них определено явное участие в одном из проектов, кроме того, водитель является пользователем, а заказ определяет время пользования клиентом назначенной парой водитель-автомобиль.


Код
class Vehicle(models.Model):
    project = models.ForeignKey(
        Project, on_delete=models.CASCADE,
        verbose_name=_('Project'), help_text=_('Project to which this vehicle belongs'),
        related_name='vehicles',
    )
    code = models.CharField(
        max_length=256,
        db_index=True,
        verbose_name=_('Code'),
        help_text=_('Registration code')
    )
    def __str__(self):
        return self.code
    class Meta:
        verbose_name = _('Vehicle')
        verbose_name_plural = _('Vehicles')
        unique_together = (
            ('code', 'project'),
        )

class Driver(models.Model):
    project = models.ForeignKey(
        Project, on_delete=models.CASCADE,
        verbose_name=_('Project'), help_text=_('Project to which this driver belongs'),
        related_name='drivers',
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
        verbose_name=_('User'), help_text=_('User who is a driver'),
        related_name='driving',
    )
    def __str__(self):
        return _('%s [%s]') % (self.user, self.project)

    class Meta:
        verbose_name = _('Driver')
        verbose_name_plural = _('Drivers')
        unique_together = (
            ('user', 'project'),
        )

class Order(models.Model):
    project = models.ForeignKey(
        Project, on_delete=models.CASCADE,
        verbose_name=_('Project'), help_text=_('Project to which this order belongs'),
        related_name='orders',
    )
    client = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
        verbose_name=_('Client'), help_text=_('Client getting an order'),
        related_name='orders',
    )
    vehicle = models.ForeignKey(
        Vehicle, on_delete=models.CASCADE,
        verbose_name=_('Vehicle'), help_text=_('Vehicle assigned to this order'),
        related_name='orders',
    )
    driver = models.ForeignKey(
        Driver, on_delete=models.CASCADE,
        verbose_name=_('Driver'), help_text=_('Driver assigned to this order'),
        related_name='orders',
    )
    start_time = models.DateTimeField(
        db_index=True,
        verbose_name=_("Start Time"),
        help_text=_("Time when the order is started")
    )
    stop_time = models.DateTimeField(
        db_index=True,
        verbose_name=_("Stop Time"),
        help_text=_("Time when the order is stopped")
    )
    def __str__(self):
        return _('%(start_time)s-%(stop_time)s %(client)s %(driver)s %(vehicle)s') % {
            'start_time': self.start_time,
            'stop_time': self.stop_time,
            'client': self.client,
            'driver': self.driver,
            'vehicle': self.vehicle,
        }
    class Meta:
        verbose_name = _('Order')
        verbose_name_plural = _('Orders')

Создание админок


Включим приложения access и trans в состав инсталлированных приложений проекта в модуле settings.py, создадим миграции командой python makemigrations trans и выполним миграцию командой python migrate.


Теперь, для всех моделей нашего приложения, сделаем админки в модуле admin.py приложения trans. В админках мы сразу же используем модифицированные классы админки из пакета Django-Access.


А эти админки будут работать?

Да. Во-первых, пакет Django-Access по умолчанию, использует систему распределения прав, аналогичную той, которая используется пакетом Django, и только для тех моделей, права к которым переопределены, использует переопределенные права.


Во-вторых, вы скорее всего, будете поначалу отлаживать админки под правами суперпользователя, которому Django-Access, по умолчанию, так же как и Django, отдает все права.


Код
@admin.register(Project)
class ProjectAdmin(AccessModelAdmin):
    fields = ['name']
    list_display = ['name']
    search_fields = ['name']

@admin.register(ProjectMember)
class ProjectMemberAdmin(AccessModelAdmin):
    fields = ['user', 'project', 'allow_manage', 'allow_change']
    list_display = ['user', 'project', 'allow_manage', 'allow_change']
    search_fields = ['user__username', 'user__first_name', 'user__last_name', 'project__name']
    list_filters = ['project', 'allow_manage', 'allow_change']
    autocomplete_fields = ['user', 'project']

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "project":
            projects = AccessManager(Project).changeable(request)
            kwargs["queryset"] = projects
        return super(ProjectMemberAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

@admin.register(Vehicle)
class VehicleAdmin(AccessModelAdmin):
    fields = ['project', 'code']
    list_display = ['code', 'project']
    list_filters = ['project']
    search_fields = ['code']

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "project":
            projects = Project.objects.filter(users__user=request.user, users__allow_change=True)
            kwargs["queryset"] = projects
        return super(VehicleAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

@admin.register(Driver)
class DriverAdmin(AccessModelAdmin):
    fields = ['project', 'user']
    list_display = ['user', 'project']
    list_filters = ['project']
    search_fields = ['user__username', 'user__first_name', 'user__last_name', 'project__name']
    autocomplete_fields = ['user', 'project']

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "project":
            projects = Project.objects.filter(users__user=request.user, users__allow_change=True)
            kwargs["queryset"] = projects
        return super(DriverAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

@admin.register(Order)
class OrderAdmin(AccessModelAdmin):
    fields = ['project', 'client', 'vehicle', 'driver', 'start_time', 'stop_time']
    list_display = ['start_time', 'stop_time', 'client', 'driver', 'vehicle', 'project']
    list_filters = ['project', 'vehicle', 'driver']
    search_fields = ['client__username', 'client__first_name', 'client__last_name', 'project__name',]
    autocomplete_fields = ['client', 'vehicle', 'driver', 'project']
    date_hierarchy = 'start_time'

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "project":
            projects = Project.objects.filter(users__user=request.user, users__allow_change=True)
            kwargs["queryset"] = projects
        return super(OrderAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

Мы должны переопределить админку пользователей, чтобы исключить оттуда неиспользуемые поля и исключить возгонку прав.


Мы также отменим регистрацию админки для групп, поскольку вовсе не собираемся использовать группы в этом проекте.


Код

Разместим следующий код над кодом админки для проекта, поскольку админка пользователей должна быть зарегистрирована к тому моменту, когда список пользователей используется в полях autocomplete:


admin.site.unregister(User)
admin.site.unregister(Group)

@admin.register(User)
class AccessUserAdmin(AccessControlMixin, UserAdmin):
    def get_readonly_fields(self, request, obj=None):
        readonly_fields = super(AccessUserAdmin, self).get_readonly_fields(request, obj) or []
        if request.user.is_superuser:
            return readonly_fields
        if not obj:
            return readonly_fields
        restrict = ['is_superuser', 'last_login', 'date_joined']
        if obj.pk != request.user.pk:
            restrict = ['is_superuser', 'last_login', 'date_joined', 'password', 'email']
        return [f for f in readonly_fields if f not in restrict] + restrict

    def get_list_display(self, request):
        fields = super(AccessUserAdmin, self).get_list_display(request) or []
        if request.user.is_superuser:
            return fields
        restrict = ['password', 'email']
        return [f for f in fields if not f in restrict]

    def _fieldsets_exclude(self, fieldsets, exclude):
        ret = []
        for nm, params in fieldsets:
            if 'fields' not in params:
                ret.append((nm, params))
                continue
            fields = []
            for f in params['fields']:
                if f not in exclude:
                    fields.append(f)
            pars = {}
            pars.update(params)
            pars['fields'] = fields
            ret.append((nm, pars))
        return ret

    def _fieldsets_only(self, fieldsets, only):
        ret = []
        for nm, params in fieldsets:
            if 'fields' not in params:
                ret.append((nm, params))
                continue
            fields = []
            for f in params['fields']:
                if f in only:
                    fields.append(f)
            pars = {}
            pars.update(params)
            pars['fields'] = fields
            ret.append((nm, pars))
        return ret

    def get_fieldsets(self, request, obj=None):
        fieldsets = list(super(AccessUserAdmin, self).get_fieldsets(request, obj)) or []
        fields = self.get_fields(request, obj=obj)
        return self._fieldsets_only(fieldsets, fields)

    def get_fields(self, request, obj=None):
        fields = list(super(AccessUserAdmin, self).get_fields(request, obj)) or []
        exclude = ['is_staff', 'groups', 'user_permissions']
        if not request.user.is_superuser:
            exclude = ['is_staff', 'is_superuser', 'groups', 'user_permissions']
            if obj and obj.pk != request.user.pk:
                exclude = ['is_staff', 'password', 'email', 'groups', 'user_permissions']
        return [f for f in fields if not f in exclude]

    def save_model(self, request, obj, form, change):
        obj.is_staff = True
        return super(AccessUserAdmin, self).save_model(request, obj, form, change)

Вы можете сейчас запустить админку, чтобы убедиться, что раздел аутентификации и авторизации содержит только админку пользователей, а также зарегистрировать нового пользователя (с установленным флагом is_staff и сброшенным флагом is_superuser), открыть приложение от его имени и увидеть, что он вообще никаких прав не имеет и ничего не может поменять, кроме своего пароля.


Создание схемы управления правами


Теперь, нам понадобится вспомогательное приложение authorize. В этом приложении, мы создадим схему управления правами, в которой укажем все взаимоотношения между объектами для определения прав доступа к ним.


А почему отдельное?

Отдельное приложение нужно для того, чтобы иметь хорошо определенную последовательность инсталлированных приложений. Мы разместим приложение authorize в конце списка INSTALLED_APPS в файле settings.py, чтобы к моменту регистрации прав все модели точно были известны системе.


INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'access',
    'trans',

    # SHOULD BE ALWAYS LAST!
    'authorize',
]

Выполним команду python manage.py startapp authorize и перейдем к редактированию файла models.py. Вместо определения моделей, мы разместим в нем все определения схемы доступа.


Зачем?

Мы пользуемся тем фактом, что все модули models.py, содержащиеся в зарегистрированных приложениях, импортируются по очереди в процессе запуска файла проекта, в том порядке, в котором они перечислены в списке INSTALLED_APPS. Таким образом, права доступа будут регистрироваться после того, как все остальные приложения уже сообщили список своих моделей системе.


Прежде всего, сообщим системе, что любой пользователь (кроме суперпользователя) не имеет никакого доступа к личной информации другого пользователя, кроме доступа на чтение. Дадим полный доступ к своей информации пользователю, имея в виду, что мы ограничили ему доступ к админке так, что он не сможет выполнить возгонку прав до суперпользователя.


Также, разрешим пользователям с правами управления доступом к проекту, добавлять в систему других пользователей и видеть список всех пользователей, но не менять информацию о них после создания. Остальные пользователи, кроме суперпользователя, смогут увидеть в системе список только тех пользователей, которые относятся к тому же проекту, что и данный пользователь. Пользователь, не состоящий ни в одном проекте, увидит в этом списке только себя.


Мне кажется такой подход к пользователям не очень правильным...

Да мне тоже, на самом деле. В реальном проекте, была использована система регистрации через социальные сети, поэтому пользователи сами могли регистрировать себе аккаунт в системе, а менеджеры проектов — только добавлять пользователей в свой проект по необходимости. В этом же примере, система регистрации через соцсети удалена для упрощения. Вы можете добавить ее сюда сами и изменить права доступа в соответствие с новыми правилами.


Код
AccessManager.register_plugins({
    User: CompoundPlugin(
        CheckAblePlugin(
            appendable=(lambda model, request: 
                {} if request.user.id and request.user.projects.filter(allow_manage=True) else
                False
            ),
        ),
        ApplyAblePlugin(
            visible=(
                lambda queryset, request:
                    queryset if request.user.projects.filter(
                        allow_manage=True
                    ) else queryset.filter(
                        projects__project__users__user=request.user.id
                    ).distinct() or queryset.filter(
                        id=request.user.id
                    ).distinct()
            ),
            changeable=(lambda queryset, request: queryset.filter(id=request.user.id).distinct()),
            deleteable=(lambda queryset, request: queryset.filter(id=request.user.id).distinct())
        )
    ),
})

Теперь определим порядок доступа к информации о проектах и членстве в них.


Во-первых, определим, что никакой пользователь, кроме суперпользователя, не может ни добавить проект в систему, ни удалить его. Также установим, что пользователи, которые являются членами проекта, могут видеть список и данные проектов, в которых они состоят.


Во-вторых, пользователи, которые имеют права управления проектом, могут менять этот проект и членство других пользователей в проекте. Остальные пользователи видят проекты и членство в проектах, в которых состоят, но не могут изменять эти данные.


Код
AccessManager.register_plugins({
    Project: CompoundPlugin(
        CheckAblePlugin(
            appendable=(lambda model, request: False),
        ),
        ApplyAblePlugin(
            visible=(lambda queryset, request: queryset.filter(
                users__user=request.user.id
            ).distinct()),
            changeable=(lambda queryset, request: queryset.filter(
                users__user=request.user.id,
                users__allow_manage=True
            ).distinct()),
            deleteable=(lambda queryset, request: queryset.none()),
        )
    ),
    ProjectMember: CompoundPlugin(
        CheckAblePlugin(
            appendable=(lambda model, request: {} if request.user.id and request.user.projects.filter(allow_manage=True) else False),
        ),
        ApplyAblePlugin(
            visible=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id
            ).distinct()),
            changeable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_manage=True
            ).distinct()),
            deleteable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_manage=True
            ).distinct()),
        )
    ),
})

Теперь, мы можем определить правила доступа пользователей к остальным объектам, через записи о членстве в проектах и распределенных в этих проектах правах. Все эти объекты мы будем считать доступными пользователю для изменения и удаления, если эти объекты принадлежат к одному из проектов, в которых пользователь имеет членство с установленным флагом allow_change.


Кроме того, мы должны обработать особые случаи — когда пользователь, не принадлежа к проекту, является исполнителем (водителем) или клиентом проекта.


Объектов, ссылающихся на пользователя, у нас несколько:


  • Driver — ссылается на пользователя, который является водителем
  • Order — ссылается на пользователя, который является клиентом, а также на водителя, который в свою очередь, ссылается на пользователя.

Учтем это в правилах доступа.


Код
AccessManager.register_plugins({
    Vehicle: CompoundPlugin(
        ApplyAblePlugin(
            visible=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id
            ).distinct()),
            changeable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_change=True
            ).distinct()),
            deleteable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_change=True
            ).distinct()),
        )
    ),
    Driver: CompoundPlugin(
        ApplyAblePlugin(
            visible=(lambda queryset, request: queryset.filter(
                Q(project__users__user=request.user.id) | Q(user=request.user)
            ).distinct()),
            changeable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_change=True
            ).distinct()),
            deleteable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_change=True
            ).distinct()),
        )
    ),
    Order: CompoundPlugin(
        ApplyAblePlugin(
            visible=(lambda queryset, request: queryset.filter(
                Q(
                    project__users__user=request.user.id
                ) | Q(
                    client=request.user
                ) | Q(
                    driver__user=request.user
                )
            ).distinct()),
            changeable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_change=True
            ).distinct()),
            deleteable=(lambda queryset, request: queryset.filter(
                project__users__user=request.user.id,
                project__users__allow_change=True
            ).distinct()),
        )
    ),
})

А что насчет создания новых объектов?

Мы не будем ограничивать право создания новых объектов, так как модель запрещает создание объектов с незаполненным полем ссылки на проект.


В настоящий момент, пакет Django-Access не может запретить добавление объектов в проект, который мы хотим сделать доступным только для чтения, поскольку не проверяет состав полей объекта перед его созданием.


Необходимо как-то ограничить список проектов, для которых пользователь может создать объект. Мы сделали это через метод админки, ограничивающий список выбора проектов. Поскольку у нас ссылка на проект везде называется project, мы написали практически одинаковый метод для всех админок, в которых есть ссылка на проект.


Для объектов ProjectMember:


    ...
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "project":
            projects = AccessManager(Project).changeable(request)
            kwargs["queryset"] = projects
        return super(ProjectMemberAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs)

Для остальных объектов:


    ...
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name == "project":
            projects = Project.objects.filter(users__user=request.user, users__allow_change=True)
            kwargs["queryset"] = projects
        return super(..., self).formfield_for_foreignkey(db_field, request, **kwargs)

(на место многоточия… мы подставим класс админки).


Осталась последняя деталь. По умолчанию, для совместимости со штатной системой прав Django, пакет Django-Access определяет порядок доступа ко всем моделям, для которых такой порядок явно не определен, как совместимый с Django. Этим занимается плагин access.plugins.DjangoAccessPlugin, установленный, как плагин по умолчанию.


Переопределим плагин по умолчанию с помощью файла настроек settings.py. Для этого объявим свой простой плагин, запрещающийдоступ к объекту для всех, кроме суперадмина, и зададим его, как плагин по умолчанию в файле настроек.


Код
class SuperOnly(AccessPluginBase):
    def check_visible(self, model, request):
        return request.user.is_superuser and {}
    def check_changeable(self, model, request):
        return request.user.is_superuser and {}
    def check_appendable(self, model, request):
        return request.user.is_superuser and {}
    def check_deleteable(self, model, request):
        return request.user.is_superuser and {}

settings.py


ACCESS_DEFAULT_PLUGIN = "authorize.models.SuperOnly"

Резюме


Мы рассмотрели почти реальную систему, содержащую информацию, доступ к которой должен регулироваться сложными правилами, основанными на отношении объектов к другим объектам системы.


С помощью пакета Django-Access, мы сформулировали почти все необходимые правила, а оставшиеся детали ограничения доступа смогли реализовать путем небольших изменений в админке.


Все правила ограничения доступа мы сформулировали в форме запросов к СУБД.


Вы можете посмотреть полные исходники работающего приложения на репозитории GitHub

Источник: https://habr.com/ru/post/500142/


Интересные статьи

Интересные статьи

Настоящий обзор основан на практике применения ISO 15926 и CFIHOS для объектов нефтегазового и нефтехимического хозяйства. Рекомендовано для объектов, схожих по объему и типу пр...
Привет! Идея в том, что бы использовать ленивые кешируемые свойства везде, где в обычном случае мы бы использовали процессорно тяжелые методы без аргументов. А статья — как это задизайни...
Когда работаешь разрабом, все время видишь, как тимлиды сидят на куче созвонов, отвечают за все, пишут столько же кода, сколько и ты, и при этом денег у них не больше. На такое по...
Один из самых острых вопросов при разработке на Битрикс - это миграции базы данных. Какие же способы облегчить эту задачу есть на данный момент?