Projet

Général

Profil

0006-misc-implement-soft-delete-on-RoleParenting-57500.patch

Benjamin Dauvergne, 01 février 2022 00:48

Télécharger (25,1 ko)

Voir les différences:

Subject: [PATCH 6/6] misc: implement soft-delete on RoleParenting (#57500)

 .../0026_add_roleparenting_soft_delete.py     | 27 +++++++
 src/authentic2/custom_user/models.py          |  5 +-
 .../management/commands/check-and-repair.py   |  8 +-
 src/authentic2/manager/resources.py           | 10 +--
 src/authentic2/manager/role_views.py          |  4 +-
 src/authentic2/manager/tables.py              |  4 +-
 src/authentic2/manager/user_views.py          |  2 +-
 src/django_rbac/apps.py                       | 10 ++-
 src/django_rbac/managers.py                   | 65 ++++++++++++---
 .../0008_add_roleparenting_soft_delete.py     | 27 +++++++
 src/django_rbac/models.py                     | 18 +++--
 src/django_rbac/signal_handlers.py            |  5 ++
 src/django_rbac/signals.py                    |  7 ++
 tests_rbac/test_rbac.py                       | 80 +++++++++++++++++--
 14 files changed, 231 insertions(+), 41 deletions(-)
 create mode 100644 src/authentic2/a2_rbac/migrations/0026_add_roleparenting_soft_delete.py
 create mode 100644 src/django_rbac/migrations/0008_add_roleparenting_soft_delete.py
 create mode 100644 src/django_rbac/signals.py
src/authentic2/a2_rbac/migrations/0026_add_roleparenting_soft_delete.py
1
# Generated by Django 2.2.19 on 2021-10-06 10:30
2

  
3
import django.utils.timezone
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('a2_rbac', '0028_ou_home_url'),
11
    ]
12

  
13
    operations = [
14
        migrations.AddField(
15
            model_name='roleparenting',
16
            name='created',
17
            field=models.DateTimeField(
18
                auto_now_add=True, default=django.utils.timezone.now, verbose_name='Creation date'
19
            ),
20
            preserve_default=False,
21
        ),
22
        migrations.AddField(
23
            model_name='roleparenting',
24
            name='deleted',
25
            field=models.DateTimeField(null=True, verbose_name='Deletion date'),
26
        ),
27
    ]
src/authentic2/custom_user/models.py
220 220

  
221 221
    def roles_and_parents(self):
222 222
        qs1 = self.roles.all()
223
        qs2 = qs1.model.objects.filter(child_relation__child__in=qs1)
223
        qs2 = qs1.model.objects.filter(
224
            child_relation__deleted__isnull=True,
225
            child_relation__child__in=qs1,
226
        )
224 227
        qs = (qs1 | qs2).order_by('name').distinct()
225 228
        rp_qs = RoleParenting.objects.filter(child__in=qs1)
226 229
        qs = qs.prefetch_related(models.Prefetch('child_relation', queryset=rp_qs), 'child_relation__parent')
src/authentic2/management/commands/check-and-repair.py
388 388
                    direct_members = manager_role.members.all()
389 389
                    direct_members_count = direct_members.count()
390 390
                    direct_children = Role.objects.filter(
391
                        parent_relation__parent=manager_role, parent_relation__direct=True
391
                        parent_relation__deleted__isnull=True,
392
                        parent_relation__parent=manager_role,
393
                        parent_relation__direct=True,
392 394
                    )
393 395
                    direct_children_count = direct_children.count()
394 396
                    show = members_count or self.verbosity > 1
......
398 400
                            self.notice('- "%s" has problematic manager roles', role)
399 401
                        self.warning('  - %s', manager_role, ending=' ')
400 402
                    direct_parents = Role.objects.filter(
401
                        child_relation__child=manager_role, child_relation__direct=True
403
                        child_relation__deleted__isnull=True,
404
                        child_relation__child=manager_role,
405
                        child_relation__direct=True,
402 406
                    )
403 407
                    if show:
404 408
                        self.warning('DELETE', ending=' ')
src/authentic2/manager/resources.py
36 36
    roles = Field()
37 37

  
38 38
    def dehydrate_roles(self, instance):
39
        result = set()
40
        for role in instance.roles.all():
41
            result.add(role)
42
            for pr in role.parent_relation.all():
43
                result.add(pr.parent)
44
        return ', '.join(str(x) for x in result)
39
        roles = {role for role in instance.roles.all()}
40
        # optimization as parent_relation is prefetched, filter deleted__isnull=True using python
41
        parents = {rp.parent for role in roles for rp in role.parent_relation.all() if not rp.deleted}
42
        return ', '.join(str(x) for x in roles | parents)
45 43

  
46 44
    class Meta:
47 45
        model = User
src/authentic2/manager/role_views.py
432 432
                Q(pk__in=children.filter(is_direct=False)), output_field=BooleanField()
433 433
            )
434 434
        )
435
        rp_qs = RoleParenting.objects.filter(parent__in=children).annotate(name=F('parent__name'))
435
        rp_qs = RoleParenting.alive.filter(parent__in=children).annotate(name=F('parent__name'))
436 436
        qs = qs.prefetch_related(Prefetch('parent_relation', queryset=rp_qs, to_attr='via'))
437 437
        return qs
438 438

  
......
494 494
                Q(pk__in=parents.filter(is_direct=False)), output_field=BooleanField()
495 495
            )
496 496
        )
497
        rp_qs = RoleParenting.objects.filter(child__in=parents).annotate(name=F('child__name'))
497
        rp_qs = RoleParenting.alive.filter(child__in=parents).annotate(name=F('child__name'))
498 498
        qs = qs.prefetch_related(Prefetch('child_relation', queryset=rp_qs, to_attr='via'))
499 499
        return qs
500 500

  
src/authentic2/manager/tables.py
202 202
    )
203 203
    ou = tables.Column()
204 204
    via = tables.TemplateColumn(
205
        '{% if not record.member %}{% for rel in record.child_relation.all %}{{ rel.child }} {% if not'
206
        ' forloop.last %}, {% endif %}{% endfor %}{% endif %}',
205
        '{% if not record.member %}{% for rel in record.child_relation.all %}'
206
        '{{ rel.child }} {% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}',
207 207
        verbose_name=_('Inherited from'),
208 208
        orderable=False,
209 209
    )
src/authentic2/manager/user_views.py
640 640
        if self.is_ou_specified():
641 641
            roles = self.object.roles.all()
642 642
            User = get_user_model()
643
            rp_qs = RoleParenting.objects.filter(child__in=roles)
643
            rp_qs = RoleParenting.alive.filter(child__in=roles)
644 644
            qs = Role.objects.all()
645 645
            qs = qs.prefetch_related(models.Prefetch('child_relation', queryset=rp_qs, to_attr='via'))
646 646
            qs = qs.prefetch_related(
src/django_rbac/apps.py
8 8
    def ready(self):
9 9
        from django.db.models.signals import post_delete, post_migrate, post_save
10 10

  
11
        from . import signal_handlers, utils
11
        from . import signal_handlers, signals, utils
12 12

  
13 13
        # update role parenting when new role parenting is created
14 14
        post_save.connect(signal_handlers.role_parenting_post_save, sender=utils.get_role_parenting_model())
......
16 16
        post_delete.connect(
17 17
            signal_handlers.role_parenting_post_delete, sender=utils.get_role_parenting_model()
18 18
        )
19
        # or soft-created
20
        signals.post_soft_create.connect(
21
            signal_handlers.role_parenting_post_soft_delete, sender=utils.get_role_parenting_model()
22
        )
23
        # or soft-deleted
24
        signals.post_soft_delete.connect(
25
            signal_handlers.role_parenting_post_soft_delete, sender=utils.get_role_parenting_model()
26
        )
19 27
        # create CRUD operations and admin
20 28
        post_migrate.connect(signal_handlers.create_base_operations, sender=self)
21 29
        # update role parenting in post migrate
src/django_rbac/managers.py
1 1
import contextlib
2
import datetime
2 3
import threading
3 4

  
4 5
from django.contrib.auth import get_user_model
......
8 9
from django.db.models.query import Prefetch, Q
9 10
from django.db.transaction import atomic
10 11

  
11
from . import utils
12
from . import signals, utils
12 13

  
13 14

  
14 15
class AbstractBaseManager(models.Manager):
......
107 108
        return self.filter(members=user).parents().distinct()
108 109

  
109 110
    def parents(self, include_self=True, annotate=False):
110
        qs = self.model.objects.filter(child_relation__child__in=self)
111
        qs = self.model.objects.filter(
112
            child_relation__deleted__isnull=True,
113
            child_relation__child__in=self,
114
        )
111 115
        if include_self:
112 116
            qs = self | qs
113 117
        qs = qs.distinct()
......
116 120
        return qs
117 121

  
118 122
    def children(self, include_self=True, annotate=False):
119
        qs = self.model.objects.filter(parent_relation__parent__in=self)
123
        qs = self.model.objects.filter(
124
            parent_relation__deleted__isnull=True,
125
            parent_relation__parent__in=self,
126
        )
120 127
        if include_self:
121 128
            qs = self | qs
122 129
        qs = qs.distinct()
......
128 135
        User = get_user_model()
129 136
        prefetch = Prefetch('roles', queryset=self, to_attr='direct')
130 137
        return (
131
            User.objects.filter(Q(roles__in=self) | Q(roles__parent_relation__parent__in=self))
138
            User.objects.filter(
139
                Q(roles__in=self)
140
                | Q(roles__parent_relation__parent__in=self, roles__parent_relation__deleted__isnull=True)
141
            )
132 142
            .distinct()
133 143
            .prefetch_related(prefetch)
134 144
        )
......
168 178
            raise self.model.DoesNotExist
169 179
        return self.get(parent=parent, child=child, direct=direct)
170 180

  
181
    def soft_create(self, parent, child):
182
        with atomic(savepoint=False):
183
            rp, created = self.get_or_create(parent=parent, child=child, direct=True)
184
            new = created or rp.deleted
185
            if not created and rp.deleted:
186
                rp.created = datetime.datetime.now()
187
                rp.deleted = None
188
                rp.save(update_fields=['created', 'deleted'])
189
            if new:
190
                signals.post_soft_create.send(sender=self.model, instance=rp)
191

  
192
    def soft_delete(self, parent, child):
193
        from . import signals
194

  
195
        qs = self.filter(parent=parent, child=child, deleted__isnull=True, direct=True)
196
        with atomic(savepoint=False):
197
            rp = qs.first()
198
            if rp:
199
                count = qs.update(deleted=datetime.datetime.now())
200
                # read-commited, view of tables can change during transaction
201
                if count:
202
                    signals.post_soft_delete.send(sender=self.model, instance=rp)
203

  
171 204
    def update_transitive_closure(self):
172 205
        """Recompute the transitive closure of the inheritance relation
173 206
        from scratch. Add missing indirect relations and delete
......
179 212

  
180 213
        with atomic(savepoint=False):
181 214
            # existing direct paths
182
            direct = set(self.filter(direct=True).values_list('parent_id', 'child_id'))
183
            old_indirects = set(self.filter(direct=False).values_list('parent_id', 'child_id'))
215
            direct = set(self.filter(direct=True, deleted__isnull=True).values_list('parent_id', 'child_id'))
216
            old_indirects = set(
217
                self.filter(direct=False, deleted__isnull=True).values_list('parent_id', 'child_id')
218
            )
184 219
            indirects = set(direct)
185 220

  
186 221
            while True:
......
197 232
                # Delete old ones
198 233
                obsolete = old_indirects - indirects - direct
199 234
                if obsolete:
200
                    obsolete_values = ', '.join('(%s, %s)' % (a, b) for a, b in obsolete)
201
                    sql = '''DELETE FROM "%s" AS relation \
202
USING (VALUES %s) AS dead(parent_id, child_id) \
235
                    sql = '''UPDATE "%s" AS relation \
236
SET deleted = now()\
237
FROM (VALUES %s) AS dead(parent_id, child_id) \
203 238
WHERE relation.direct = 'false' AND relation.parent_id = dead.parent_id \
204
AND relation.child_id = dead.child_id''' % (
239
AND relation.child_id = dead.child_id AND deleted IS NULL''' % (
205 240
                        self.model._meta.db_table,
206
                        obsolete_values,
241
                        ', '.join('(%s, %s)' % (a, b) for a, b in obsolete),
207 242
                    )
208 243
                    cur.execute(sql)
209 244
                # Create new indirect relations
210 245
                new = indirects - old_indirects - direct
211 246
                if new:
212 247
                    new_values = ', '.join(
213
                        ("(%s, %s, 'false')" % (parent_id, child_id) for parent_id, child_id in new)
248
                        (
249
                            "(%s, %s, 'false', now(), NULL)" % (parent_id, child_id)
250
                            for parent_id, child_id in new
251
                        )
214 252
                    )
215
                    sql = '''INSERT INTO "%s" (parent_id, child_id, direct) VALUES %s''' % (
253
                    sql = '''INSERT INTO "%s" (parent_id, child_id, direct, created, deleted) VALUES %s \
254
ON CONFLICT (parent_id, child_id, direct) DO UPDATE SET created = EXCLUDED.created, deleted = NULL''' % (
216 255
                        self.model._meta.db_table,
217 256
                        new_values,
218 257
                    )
src/django_rbac/migrations/0008_add_roleparenting_soft_delete.py
1
# Generated by Django 2.2.19 on 2021-10-06 10:34
2

  
3
import django.utils.timezone
4
from django.db import migrations, models
5

  
6

  
7
class Migration(migrations.Migration):
8

  
9
    dependencies = [
10
        ('django_rbac', '0007_add_unique_constraints'),
11
    ]
12

  
13
    operations = [
14
        migrations.AddField(
15
            model_name='roleparenting',
16
            name='created',
17
            field=models.DateTimeField(
18
                auto_now_add=True, default=django.utils.timezone.now, verbose_name='Creation date'
19
            ),
20
            preserve_default=False,
21
        ),
22
        migrations.AddField(
23
            model_name='roleparenting',
24
            name='deleted',
25
            field=models.DateTimeField(null=True, verbose_name='Deletion date'),
26
        ),
27
    ]
src/django_rbac/models.py
14 14
from django.db.models.query import Prefetch, Q
15 15
from django.utils.translation import gettext
16 16
from django.utils.translation import ugettext_lazy as _
17
from model_utils.managers import QueryManager
17 18

  
18 19
from . import backends, constants, managers, utils
19 20

  
......
186 187

  
187 188
    def add_child(self, child):
188 189
        RoleParenting = utils.get_role_parenting_model()
189
        RoleParenting.objects.get_or_create(parent=self, child=child, direct=True)
190
        RoleParenting.objects.soft_create(self, child)
190 191

  
191 192
    def remove_child(self, child):
192 193
        RoleParenting = utils.get_role_parenting_model()
193
        RoleParenting.objects.filter(parent=self, child=child, direct=True).delete()
194
        RoleParenting.objects.soft_delete(self, child)
194 195

  
195 196
    def add_parent(self, parent):
196 197
        RoleParenting = utils.get_role_parenting_model()
197
        RoleParenting.objects.get_or_create(parent=parent, child=self, direct=True)
198
        RoleParenting.objects.soft_create(parent, self)
198 199

  
199 200
    def remove_parent(self, parent):
200 201
        RoleParenting = utils.get_role_parenting_model()
201
        RoleParenting.objects.filter(child=self, parent=parent, direct=True).delete()
202
        RoleParenting.objects.soft_delete(parent, self)
202 203

  
203 204
    def parents(self, include_self=True, annotate=False):
204 205
        return self.__class__.objects.filter(pk=self.pk).parents(include_self=include_self, annotate=annotate)
......
211 212
    def all_members(self):
212 213
        User = get_user_model()
213 214
        prefetch = Prefetch('roles', queryset=self.__class__.objects.filter(pk=self.pk), to_attr='direct')
215

  
214 216
        return (
215
            User.objects.filter(Q(roles=self) | Q(roles__parent_relation__parent=self))
217
            User.objects.filter(
218
                Q(roles=self)
219
                | Q(roles__parent_relation__parent=self) & Q(roles__parent_relation__deleted__isnull=True)
220
            )
216 221
            .distinct()
217 222
            .prefetch_related(prefetch)
218 223
        )
......
249 254
        on_delete=models.CASCADE,
250 255
    )
251 256
    direct = models.BooleanField(default=True, blank=True)
257
    created = models.DateTimeField(verbose_name=_('Creation date'), auto_now_add=True)
258
    deleted = models.DateTimeField(verbose_name=_('Deletion date'), null=True)
252 259

  
253 260
    objects = managers.RoleParentingManager()
261
    alive = QueryManager(deleted__isnull=True)
254 262

  
255 263
    def natural_key(self):
256 264
        return [self.parent.natural_key(), self.child.natural_key(), self.direct]
src/django_rbac/signal_handlers.py
19 19
    sender.objects.update_transitive_closure()
20 20

  
21 21

  
22
def role_parenting_post_soft_delete(sender, instance, **kwargs):
23
    '''Close the role parenting relation after instance soft-deletion'''
24
    sender.objects.update_transitive_closure()
25

  
26

  
22 27
def create_base_operations(app_config, verbosity=2, interactive=True, using=DEFAULT_DB_ALIAS, **kwargs):
23 28
    '''Create some basic operations, matching permissions from Django'''
24 29
    if not router.allow_migrate(using, models.Operation):
src/django_rbac/signals.py
1
from django import dispatch
2

  
3
from . import signal_handlers, utils
4

  
5
# update role parenting transitive closure when role parenting is deleted
6
post_soft_create = dispatch.Signal(providing_args=['instance'])
7
post_soft_delete = dispatch.Signal(providing_args=['instance'])
tests_rbac/test_rbac.py
41 41
    assert Role.objects.count() == 10
42 42
    assert RoleParenting.objects.count() == 0
43 43
    for i in range(1, 3):
44
        RoleParenting.objects.create(parent=roles[i - 1], child=roles[i])
44
        RoleParenting.objects.soft_create(parent=roles[i - 1], child=roles[i])
45 45
    assert RoleParenting.objects.filter(direct=True).count() == 2
46 46
    assert RoleParenting.objects.filter(direct=False).count() == 1
47 47
    for i, role in enumerate(roles[:3]):
......
59 59
        assert role.parents().count() == i + 1
60 60
        assert role.children(False).count() == 3 - i - 1
61 61
        assert role.parents(False).count() == i
62
    RoleParenting.objects.create(parent=roles[2], child=roles[3])
62
    RoleParenting.objects.soft_create(parent=roles[2], child=roles[3])
63 63
    assert RoleParenting.objects.filter(direct=True).count() == 5
64 64
    assert RoleParenting.objects.filter(direct=False).count() == 10
65 65
    for i in range(6):
......
69 69
        assert role.parents().count() == i + 1
70 70
        assert role.children(False).count() == 6 - i - 1
71 71
        assert role.parents(False).count() == i
72
    RoleParenting.objects.filter(parent=roles[2], child=roles[3], direct=True).delete()
73
    assert RoleParenting.objects.filter(direct=True).count() == 4
74
    assert RoleParenting.objects.filter(direct=False).count() == 2
72
    RoleParenting.objects.soft_delete(roles[2], roles[3])
73
    assert (
74
        RoleParenting.objects.filter(
75
            direct=True,
76
            deleted__isnull=True,
77
        ).count()
78
        == 4
79
    )
80
    assert (
81
        RoleParenting.objects.filter(
82
            direct=False,
83
            deleted__isnull=True,
84
        ).count()
85
        == 2
86
    )
75 87
    # test that it works with cycles
76
    RoleParenting.objects.create(parent=roles[2], child=roles[3])
77
    RoleParenting.objects.create(parent=roles[5], child=roles[0])
88
    RoleParenting.objects.soft_create(parent=roles[2], child=roles[3])
89
    RoleParenting.objects.soft_create(parent=roles[5], child=roles[0])
78 90
    for role in roles[:6]:
79 91
        assert role.children().count() == 6
80 92
        assert role.parents().count() == 6
81 93

  
82 94

  
95
def test_role_parenting_soft_delete_children(db):
96
    OrganizationalUnit = utils.get_ou_model()
97
    Role = utils.get_role_model()
98
    RoleParenting = utils.get_role_parenting_model()
99

  
100
    ou = OrganizationalUnit.objects.create(name='ou')
101
    roles = []
102
    for i in range(10):
103
        roles.append(Role.objects.create(name='r%d' % i, ou=ou))
104
    assert not len(RoleParenting.objects.all())
105

  
106
    rps = []
107
    for i in range(5):
108
        rps.append(RoleParenting.objects.soft_create(parent=roles[9 - i], child=roles[i]))
109
    assert len(RoleParenting.objects.all()) == 5
110
    for i in range(5):
111
        roles[9 - i].remove_child(roles[i])
112
        assert len(RoleParenting.objects.all()) == 5
113
        assert len(RoleParenting.objects.filter(deleted__isnull=True).all()) == 4 - i
114
    for i in range(5):
115
        roles[9 - i].add_child(roles[i])
116
        assert len(RoleParenting.objects.all()) == 5
117
        assert len(RoleParenting.objects.filter(deleted__isnull=True).all()) == i + 1
118

  
119

  
120
def test_role_parenting_soft_delete_parents(db):
121
    OrganizationalUnit = utils.get_ou_model()
122
    Role = utils.get_role_model()
123
    RoleParenting = utils.get_role_parenting_model()
124

  
125
    ou = OrganizationalUnit.objects.create(name='ou')
126
    roles = []
127
    for i in range(10):
128
        roles.append(Role.objects.create(name='r%d' % i, ou=ou))
129
    assert not len(RoleParenting.objects.all())
130

  
131
    rps = []
132
    for i in range(5):
133
        rps.append(RoleParenting.objects.soft_create(child=roles[9 - i], parent=roles[i]))
134
    assert len(RoleParenting.objects.all()) == 5
135
    for i in range(5):
136
        roles[9 - i].remove_parent(roles[i])
137
        assert len(RoleParenting.objects.all()) == 5
138
        assert len(RoleParenting.objects.filter(deleted__isnull=True).all()) == 4 - i
139
    for i in range(5):
140
        roles[9 - i].add_parent(roles[i])
141
        assert len(RoleParenting.objects.all()) == 5
142
        assert len(RoleParenting.objects.filter(deleted__isnull=True).all()) == i + 1
143

  
144

  
83 145
SIZE = 1000
84 146
SPAN = 50
85 147

  
......
255 317
                break
256 318
            z = new_z
257 319
        real = np.zeros((c, c), dtype=bool)
258
        for parent_id, child_id in RoleParenting.objects.values_list('parent_id', 'child_id'):
320
        for parent_id, child_id in RoleParenting.objects.filter(deleted__isnull=True).values_list(
321
            'parent_id', 'child_id'
322
        ):
259 323
            real[parent_id][child_id] = True
260 324
        assert np.array_equal(real, z & ~one)
261 325

  
262
-