Projet

Général

Profil

0002-misc-replace-DeletedUser-model-by-attribute-deleted-.patch

Benjamin Dauvergne, 22 avril 2020 02:39

Télécharger (25 ko)

Voir les différences:

Subject: [PATCH 2/3] misc: replace DeletedUser model by attribute deleted on
 User

 src/authentic2/admin.py                       |  5 ---
 src/authentic2/custom_user/managers.py        | 20 +++++++++-
 .../migrations/0019_user_deleted.py           | 37 +++++++++++++++++
 src/authentic2/custom_user/models.py          | 20 +++++++++-
 .../commands/clean-unused-accounts.py         |  3 +-
 .../authentic2/manager/user_detail.html       |  7 ----
 src/authentic2/manager/user_views.py          | 13 ++++--
 src/authentic2/managers.py                    | 16 --------
 .../migrations/0027_auto_20200421_1609.py     | 40 +++++++++++++++++++
 src/authentic2/models.py                      | 16 --------
 src/authentic2/views.py                       | 12 +++---
 tests/test_all.py                             |  1 +
 tests/test_api.py                             |  4 +-
 tests/test_cleanup.py                         |  5 +--
 tests/test_commands.py                        | 27 ++++++++-----
 tests/test_user_manager.py                    | 17 ++++----
 tests/test_views.py                           | 18 +++++----
 17 files changed, 174 insertions(+), 87 deletions(-)
 create mode 100644 src/authentic2/custom_user/migrations/0019_user_deleted.py
 create mode 100644 src/authentic2/migrations/0027_auto_20200421_1609.py
src/authentic2/admin.py
84 84
admin.site.register(models.UserExternalId, UserExternalIdAdmin)
85 85

  
86 86

  
87
class DeletedUserAdmin(admin.ModelAdmin):
88
    list_display = ('user', 'creation')
89
    date_hierarchy = 'creation'
90
admin.site.register(models.DeletedUser, DeletedUserAdmin)
91

  
92 87
DB_SESSION_ENGINES = (
93 88
    'django.contrib.sessions.backends.db',
94 89
    'django.contrib.sessions.backends.cached_db',
src/authentic2/custom_user/managers.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from django.db import models
17
import datetime
18
import logging
19

  
20
from django.db import models, transaction
18 21
from django.utils import six
19 22
from django.utils import timezone
20 23
from django.contrib.auth.models import BaseUserManager
......
81 84

  
82 85
    def get_by_natural_key(self, uuid):
83 86
        return self.get(uuid=uuid)
87

  
88
    @transaction.atomic
89
    def cleanup(self, threshold=600, timestamp=None):
90
        '''Delete all deleted users for more than 10 minutes.'''
91
        not_after = (timestamp or timezone.now()) - datetime.timedelta(seconds=threshold)
92
        qs = self.filter(deleted__lt=not_after)
93

  
94
        loaded = list(qs)
95

  
96
        def log():
97
            logger = logging.getLogger('authentic2')
98
            for user in loaded:
99
                logger.info(u'deleted account %s', user)
100
        transaction.on_commit(log)
101
        qs.delete()
src/authentic2/custom_user/migrations/0019_user_deleted.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.29 on 2020-04-21 13:38
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import django.contrib.postgres.fields.jsonb
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('custom_user', '0018_user_last_account_deletion_alert'),
13
    ]
14

  
15
    operations = [
16
        migrations.AddField(
17
            model_name='user',
18
            name='deleted',
19
            field=models.DateTimeField(blank=True, null=True, verbose_name='Deletion date'),
20
        ),
21
        migrations.CreateModel(
22
            name='DeletedUser',
23
            fields=[
24
                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25
                ('deleted', models.DateTimeField(verbose_name='Deletion date')),
26
                ('old_uuid', models.TextField(blank=True, null=True, verbose_name='Old UUID')),
27
                ('old_user_id', models.PositiveIntegerField(blank=True, null=True, verbose_name='Old user id')),
28
                ('old_email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Old email adress')),
29
                ('old_data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True, verbose_name='Old data')),
30
            ],
31
            options={
32
                'verbose_name': 'deleted user',
33
                'verbose_name_plural': 'deleted users',
34
                'ordering': ('deleted', 'id'),
35
            },
36
        ),
37
    ]
src/authentic2/custom_user/models.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import random
18

  
17 19
from django.db import models, transaction
18 20
from django.utils import timezone
19 21
from django.core.mail import send_mail
......
155 157
        default=True,
156 158
        help_text=_('Designates whether this user should be treated as '
157 159
                    'active. Unselect this instead of deleting accounts.'))
158
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
159 160
    ou = models.ForeignKey(
160 161
        verbose_name=_('organizational unit'),
161 162
        to='a2_rbac.OrganizationalUnit',
162 163
        blank=True,
163 164
        null=True,
164 165
        swappable=False)
166

  
167
    # events dates
168
    date_joined = models.DateTimeField(_('date joined'), default=timezone.now)
165 169
    modified = models.DateTimeField(
166 170
        verbose_name=_('Last modification time'),
167 171
        db_index=True,
......
170 174
        verbose_name=_('Last account deletion alert'),
171 175
        null=True,
172 176
        blank=True)
177
    deleted = models.DateTimeField(
178
        verbose_name=_('Deletion date'),
179
        null=True,
180
        blank=True)
173 181

  
174 182
    objects = UserManager.from_queryset(UserQuerySet)()
175 183
    attributes = AttributesDescriptor()
......
323 331
        if hasattr(self, '_a2_attributes_cache'):
324 332
            del self._a2_attributes_cache
325 333
        return super(User, self).refresh_from_db(*args, **kwargs)
334

  
335
    def mark_as_deleted(self, timestamp=None, force=True, save=True):
336
        self.is_active = False
337
        self.email_verified = False
338
        if self.email and '#' not in self.email:
339
            self.email += '#%d' % random.randint(1, 10000000)
340
        if force or not self.deleted:
341
            self.deleted = timestamp or timezone.now()
342
        if save:
343
            self.save(update_fields=['email', 'email_verified', 'is_active', 'deleted'])
src/authentic2/management/commands/clean-unused-accounts.py
27 27
from django.utils.six.moves.urllib import parse as urlparse
28 28
from django_rbac.utils import get_ou_model
29 29

  
30
from authentic2.models import DeletedUser
31 30
from authentic2.utils import send_templated_mail
32 31

  
33 32
from django.conf import settings
......
120 119
        ctx = {'user': user}
121 120
        with atomic():
122 121
            if not self.fake:
123
                DeletedUser.objects.delete_user(user)
122
                user.mark_as_deleted(timestamp=self.now)
124 123
            self.send_mail('authentic2/unused_account_delete', user, ctx)
src/authentic2/manager/templates/authentic2/manager/user_detail.html
41 41
{% endblock %}
42 42

  
43 43
{% block other_actions %}
44

  
45
  {% if object.deletion %}
46
    <p class="a2-manager-user-deletion">
47
      {% blocktrans with date=object.deletion.creation %}Prepared for deletion since {{ date }}{% endblocktrans %}
48
    </p>
49
  {% endif %}
50

  
51 44
  <p class="a2-manager-user-last-login">
52 45
    {% if object.last_login %}
53 46
      {% blocktrans with date=object.last_login %}Last login on {{ date }}.{% endblocktrans %}
src/authentic2/manager/user_views.py
30 30
from django.contrib.contenttypes.models import ContentType
31 31
from django.contrib import messages
32 32
from django.views.generic import FormView, TemplateView, DetailView
33
from django.http import Http404, FileResponse
33
from django.http import Http404, FileResponse, HttpResponseRedirect
34 34

  
35 35
import tablib
36 36

  
......
71 71

  
72 72
    def get_queryset(self):
73 73
        qs = super(UsersView, self).get_queryset()
74
        qs = qs.filter(deletion__isnull=True)
74
        qs = qs.filter(deleted__isnull=True)
75 75
        qs = qs.select_related('ou')
76 76
        qs = qs.prefetch_related('roles', 'roles__parent_relation__parent')
77 77
        return qs
......
219 219
    template_name = 'authentic2/manager/user_detail.html'
220 220
    slug_field = 'uuid'
221 221

  
222
    def get_queryset(self):
223
        qs = super(UserDetailView, self).get_queryset()
224
        qs = qs.filter(deleted__isnull=True)
225
        return qs
226

  
222 227
    @property
223 228
    def title(self):
224 229
        return self.object.get_full_name()
......
630 635
        return reverse('a2-manager-users')
631 636

  
632 637
    def delete(self, request, *args, **kwargs):
633
        response = super(UserDeleteView, self).delete(request, *args, **kwargs)
638
        self.get_object().mark_as_deleted()
634 639
        hooks.call_hooks('event', name='manager-delete-user', user=request.user,
635 640
                         instance=self.object)
636
        return response
641
        return HttpResponseRedirect(self.get_success_url())
637 642

  
638 643

  
639 644
user_delete = UserDeleteView.as_view()
src/authentic2/managers.py
44 44
GetByNameManager = GetByNameQuerySet.as_manager
45 45

  
46 46

  
47
class DeletedUserManager(models.Manager):
48
    def delete_user(self, user):
49
        user.is_active = False
50
        user.save()
51
        self.get_or_create(user=user)
52

  
53
    def cleanup(self, threshold=600, timestamp=None):
54
        '''Delete all deleted users for more than 10 minutes.'''
55
        not_after = (timestamp or now()) - timedelta(seconds=threshold)
56
        for deleted_user in self.filter(creation__lte=not_after):
57
            user = deleted_user.user
58
            deleted_user.delete()
59
            user.delete()
60
            logger.info(u'deleted account %s', user)
61

  
62

  
63 47
class AuthenticationEventManager(models.Manager):
64 48
    def cleanup(self):
65 49
        # expire after one week
src/authentic2/migrations/0027_auto_20200421_1609.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.29 on 2020-04-21 14:09
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6

  
7

  
8
def noop(apps, schema_editor):
9
    pass
10

  
11

  
12
def fill_deleted(apps, schema_editor):
13
    DeletedUser = apps.get_model('authentic2', 'DeletedUser')
14
    User = apps.get_model('custom_user', 'User')
15
    User.objects.update(
16
        deleted=models.Subquery(DeletedUser.objects.filter(user_id=models.OuterRef('id')).values_list('creation')[:1]))
17

  
18

  
19
class Migration(migrations.Migration):
20

  
21
    dependencies = [
22
        ('authentic2', '0026_token'),
23
        ('custom_user', '0019_user_deleted'),
24
    ]
25

  
26
    operations = [
27
        migrations.RunPython(fill_deleted, noop),
28
        migrations.RemoveField(
29
            model_name='deleteduser',
30
            name='user',
31
        ),
32
        migrations.AlterField(
33
            model_name='attribute',
34
            name='scopes',
35
            field=models.CharField(blank=True, default='', help_text='scopes separated by spaces', max_length=256, verbose_name='scopes'),
36
        ),
37
        migrations.DeleteModel(
38
            name='DeletedUser',
39
        ),
40
    ]
src/authentic2/models.py
46 46
from .utils import ServiceAccessDenied
47 47

  
48 48

  
49
class DeletedUser(models.Model):
50
    '''Record users to delete'''
51

  
52
    objects = managers.DeletedUserManager()
53

  
54
    user = models.OneToOneField(
55
        to=settings.AUTH_USER_MODEL,
56
        related_name='deletion',
57
        verbose_name=_('user'))
58
    creation = models.DateTimeField(auto_now_add=True, verbose_name=_('creation date'))
59

  
60
    class Meta:
61
        verbose_name = _('user to delete')
62
        verbose_name_plural = _('users to delete')
63

  
64

  
65 49
@six.python_2_unicode_compatible
66 50
class UserExternalId(models.Model):
67 51
    user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('user'))
src/authentic2/views.py
1120 1120
            user_pk = deletion_token['user_pk']
1121 1121
            self.user = get_user_model().objects.get(pk=user_pk)
1122 1122
            # A user account wont be deactived twice
1123
            if self.user.deleted:
1124
                raise ValidationError(
1125
                    _('This account has previously been deleted.'))
1123 1126
            if not self.user.is_active:
1124 1127
                raise ValidationError(
1125
                    _('This account had previously been deactivated and will be deleted soon.'))
1128
                    _('This account is inactive, it cannot be deleted.'))
1126 1129
            logger.info('user %s confirmed the deletion of their own account', self.user)
1127 1130
        except signing.SignatureExpired:
1128 1131
            error = _('The account deletion request is too old, try again')
......
1140 1143
        return utils.redirect(request, 'auth_homepage')
1141 1144

  
1142 1145
    def post(self, request, *args, **kwargs):
1143
        if 'cancel' not in request.POST:
1146
        if 'cancel' not in request.POST and not self.user.deleted:
1144 1147
            utils.send_account_deletion_mail(self.request, self.user)
1145
            models.DeletedUser.objects.delete_user(self.user)
1146
            self.user.email += '#%d' % random.randint(1, 10000000)
1147
            self.user.email_verified = False
1148
            self.user.save(update_fields=['email', 'email_verified'])
1148
            self.user.mark_as_deleted()
1149 1149
            logger.info(u'deletion of account %s performed', self.user)
1150 1150
            hooks.call_hooks('event', name='delete-account', user=self.user)
1151 1151
            if self.user == request.user:
tests/test_all.py
90 90
                    'user_permissions': [],
91 91
                    'password': '',
92 92
                    'ou': None,
93
                    'deleted': None,
93 94
                }
94 95
            },
95 96
            {
tests/test_api.py
417 417
                    'last_name_verified', 'date_joined', 'last_login',
418 418
                    'username', 'password', 'email', 'is_active', 'title',
419 419
                    'title_verified', 'modified', 'email_verified',
420
                    'last_account_deletion_alert']) == set(resp.json.keys())
420
                    'last_account_deletion_alert', 'deleted']) == set(resp.json.keys())
421 421
        assert resp.json['first_name'] == payload['first_name']
422 422
        assert resp.json['last_name'] == payload['last_name']
423 423
        assert resp.json['email'] == payload['email']
......
484 484
                    'last_name_verified', 'date_joined', 'last_login',
485 485
                    'username', 'password', 'email', 'is_active', 'title',
486 486
                    'title_verified', 'modified', 'email_verified',
487
                    'last_account_deletion_alert']) == set(resp.json.keys())
487
                    'last_account_deletion_alert', 'deleted']) == set(resp.json.keys())
488 488
        user = get_user_model().objects.get(pk=resp.json['id'])
489 489
        assert AttributeValue.objects.with_owner(user).filter(verified=True).count() == 3
490 490
        assert AttributeValue.objects.with_owner(user).filter(verified=False).count() == 0
tests/test_cleanup.py
16 16

  
17 17
import datetime
18 18

  
19
from authentic2.models import DeletedUser
20 19
from django.contrib.auth import get_user_model
21 20
from django.utils.timezone import now
22 21

  
......
25 24
    User = get_user_model()
26 25
    u = User.objects.create(username='john.doe')
27 26
    assert User.objects.count() == 1
28
    DeletedUser.objects.delete_user(u)
29
    DeletedUser.objects.cleanup(timestamp=now() + datetime.timedelta(seconds=700))
27
    u.mark_as_deleted()
28
    User.objects.cleanup(timestamp=now() + datetime.timedelta(seconds=700))
30 29
    assert User.objects.count() == 0
tests/test_commands.py
25 25
from django.utils.timezone import now
26 26
import py
27 27

  
28
from authentic2.models import Attribute, DeletedUser
29 28
from authentic2_auth_oidc.models import OIDCProvider
30 29
from django_rbac.utils import get_ou_model
31 30

  
32 31
from utils import login
33 32

  
34 33
if six.PY2:
35
    FileType = file
34
    FileType = file  # noqa: F821
36 35
else:
37 36
    from io import TextIOWrapper, BufferedReader, BufferedWriter
38 37
    FileType = (TextIOWrapper, BufferedReader, BufferedWriter)
......
61 60
    simple_user.save()
62 61

  
63 62
    management.call_command('clean-unused-accounts')
64
    assert not DeletedUser.objects.filter(user=simple_user).exists()
63
    simple_user.refresh_from_db()
64
    assert not simple_user.deleted
65 65
    assert len(mailoutbox) == 1
66 66

  
67 67
    freezer.move_to('2018-01-01 12:00:00')
68 68
    # no new mail, no deletion
69 69
    management.call_command('clean-unused-accounts')
70
    assert not DeletedUser.objects.filter(user=simple_user).exists()
70
    simple_user.refresh_from_db()
71
    assert not simple_user.deleted
71 72
    assert len(mailoutbox) == 1
72 73

  
73 74
    freezer.move_to('2018-01-02')
74 75
    management.call_command('clean-unused-accounts')
75
    assert DeletedUser.objects.filter(user=simple_user).exists()
76
    simple_user.refresh_from_db()
77
    assert simple_user.deleted
76 78
    assert len(mailoutbox) == 2
77 79

  
78 80

  
......
92 94

  
93 95
    # the day of deletion, nothing happens
94 96
    freezer.move_to('2018-01-02')
95
    assert not DeletedUser.objects.filter(user=simple_user).exists()
97
    simple_user.refresh_from_db()
98
    assert not simple_user.deleted
96 99
    assert len(mailoutbox) == 1
97 100

  
98 101
    # when new alert delay is reached, user gets alerted again
99 102
    freezer.move_to('2018-01-04')
100 103
    management.call_command('clean-unused-accounts')
101
    assert not DeletedUser.objects.filter(user=simple_user).exists()
104
    simple_user.refresh_from_db()
105
    assert not simple_user.deleted
102 106
    assert len(mailoutbox) == 2
103 107

  
104 108

  
......
107 111
    simple_user.save()
108 112

  
109 113
    management.call_command('clean-unused-accounts')
110
    assert not DeletedUser.objects.filter(user=simple_user).exists()
114
    simple_user.refresh_from_db()
115
    assert not simple_user.deleted
111 116
    assert len(mailoutbox) == 0
112 117

  
113 118

  
......
121 126

  
122 127
    # even if account last login in past deletion delay, an alert is always sent first
123 128
    management.call_command('clean-unused-accounts')
124
    assert not len(DeletedUser.objects.filter(user=simple_user))
129
    simple_user.refresh_from_db()
130
    assert not simple_user.deleted
125 131
    assert len(mailoutbox) == 1
126 132

  
127 133
    # and calling again as no effect, since one day must pass before account is deleted
128 134
    management.call_command('clean-unused-accounts')
129
    assert not len(DeletedUser.objects.filter(user=simple_user))
135
    simple_user.refresh_from_db()
136
    assert not simple_user.deleted
130 137
    assert len(mailoutbox) == 1
131 138

  
132 139

  
tests/test_user_manager.py
29 29
from django_rbac.utils import get_ou_model
30 30

  
31 31
from authentic2.custom_user.models import User
32
from authentic2.models import Attribute, AttributeValue, DeletedUser
32
from authentic2.models import Attribute, AttributeValue
33 33
from authentic2.a2_rbac.utils import get_default_ou
34 34
from authentic2.manager import user_import
35 35

  
......
428 428

  
429 429

  
430 430
def test_detail_view(app, admin, simple_user):
431
    url = '/manage/users/{user.id}/'.format(user=simple_user)
432
    response = login(app, admin, url)
433
    assert not response.pyquery('.a2-manager-user-deletion')
434
    DeletedUser.objects.create(user=simple_user)
435
    response = app.get(url)
436
    assert response.pyquery('.a2-manager-user-deletion')
431
    url = '/manage/users/{user.pk}/'.format(user=simple_user)
432
    login(app, admin, url)
433

  
434

  
435
def test_detail_view_deleted(app, admin, simple_user):
436
    url = '/manage/users/{user.pk}/'.format(user=simple_user)
437
    login(app, admin, url)
438
    simple_user.mark_as_deleted()
439
    app.get(url, status=404)
437 440

  
438 441

  
439 442
def test_user_import_row_error_display(transactional_db, app, admin, media):
tests/test_views.py
51 51
    page = app.get(link)
52 52
    # FIXME: webtest does not set the Referer header, so the logout page will always ask for
53 53
    # confirmation under tests
54
    response = page.form.submit(name='delete').follow()
55
    response = response.form.submit()
56
    assert not User.objects.get(pk=simple_user.pk).is_active
54
    response = page.form.submit(name='delete')
55
    assert '_auth_user_id' not in app.session
56
    email = simple_user.email
57
    simple_user.refresh_from_db()
58
    assert not simple_user.is_active
59
    assert simple_user.deleted
60
    assert simple_user.email != email
57 61
    assert len(mailoutbox) == 2
58 62
    assert 'Account deletion on testserver' == mailoutbox[1].subject
59
    assert [simple_user.email] == mailoutbox[0].to
63
    assert mailoutbox[0].to == [email]
60 64
    assert urlparse(response.location).path == '/'
61 65
    response = response.follow().follow()
62 66
    assert response.request.url.endswith('/login/?next=/')
......
112 116
    freezer.move_to('2019-08-01')
113 117
    page = login(app, simple_user, path=reverse('delete_account'))
114 118
    page.form.submit(name='submit').follow()
115
    freezer.move_to('2019-08-04') # Too late...
119
    freezer.move_to('2019-08-04')  # Too late...
116 120
    link = get_link_from_mail(mailoutbox[0])
117 121
    response = app.get(link).follow()
118 122
    assert "The account deletion request is too old, try again" in response.text
......
133 137
    link = get_link_from_mail(mailoutbox[0])
134 138
    simple_user.is_active = False
135 139
    simple_user.save()
136
    response = app.get(link).follow()
137
    assert "This account had previously been deactivated" in response.text
140
    response = app.get(link).maybe_follow()
141
    assert 'This account is inactive, it cannot be deleted.' in response.text
138 142

  
139 143

  
140 144
def test_login_invalid_next(app):
141
-