tasks.py 8.52 KB
Newer Older
1
#   tasks.py -- task to remove old and uploaded packages from Debexpo
2
#
Baptiste Beauplat's avatar
Baptiste Beauplat committed
3
4
#   This file is part of debexpo -
#   https://salsa.debian.org/mentors.debian.net-team/debexpo
5
6
#
#   Copyright © 2011 Arno Töll <debian@toell.net>
7
#   Copyright © 2020 Baptiste Beauplat <lyknode@cilg.org>
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#
#   Permission is hereby granted, free of charge, to any person
#   obtaining a copy of this software and associated documentation
#   files (the "Software"), to deal in the Software without
#   restriction, including without limitation the rights to use,
#   copy, modify, merge, publish, distribute, sublicense, and/or sell
#   copies of the Software, and to permit persons to whom the
#   Software is furnished to do so, subject to the following
#   conditions:
#
#   The above copyright notice and this permission notice shall be
#   included in all copies or substantial portions of the Software.
#
#   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
#   OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
#   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
#   HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
#   WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
#   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
#   OTHER DEALINGS IN THE SOFTWARE.

30
from celery import shared_task
31
from datetime import timedelta, datetime, timezone
32
33
34
from logging import getLogger
from debian.deb822 import Changes
from debian.debian_support import NativeVersion
35
36

from django.conf import settings
37
from django.db.models import Max, Q
38
from django.db import transaction
39
40
41
42
43
44

from debexpo.packages.models import Package, PackageUpload
from debexpo.repository.models import Repository
from debexpo.tools.email import Email
from debexpo.bugs.models import Bug
from debexpo.tools.gitstorage import GitStorage
45
46
from debexpo.nntp.models import NNTPFeed
from debexpo.tools.nntp import NNTPClient
47
48
from debexpo.tools.clients import ExceptionClient
from debexpo.tools.clients.ftp_master import ClientFTPMaster
49
50

log = getLogger(__name__)
51
52


53
54
55
56
57
58
def remove_user_uploads(user):
    uploads = PackageUpload.objects.filter(uploader=user)

    remove_uploads(uploads)


59
@transaction.atomic
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def remove_uploads(uploads):
    removals = set()
    repository = Repository(settings.REPOSITORY)
    git_storage_path = getattr(settings, 'GIT_STORAGE', None)

    for upload in uploads:
        remove_version = not PackageUpload.objects.filter(
            package=upload.package, version=upload.version) \
            .exclude(id=upload.id)
        remove_package = not PackageUpload.objects.filter(
            package=upload.package).exclude(id=upload.id)
        package = upload.package.name
        distribution = upload.distribution.name
        uploader = upload.uploader

        if remove_version:
            repository.remove(package, upload.version)
        if remove_package:
            if git_storage_path:
                git_storage = GitStorage(git_storage_path, package)
                git_storage.remove()

            Bug.objects.remove_bugs(package)

        upload.delete()

        if remove_package:
            Package.objects.get(name=package).delete()

        removals.add((package, distribution, uploader))

    repository.update()

93
94
95
96
    for package, distribution, uploader in removals:
        log.info(f'Removed package {package}, from {distribution}, '
                 f'uploaded by {uploader}')

97
98
99
    return removals


100
@shared_task
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def remove_old_uploads():
    expiration_date = datetime.now(timezone.utc) - \
        timedelta(weeks=settings.MAX_AGE_UPLOAD_WEEKS)
    packages = Package.objects \
        .values('name', 'packageupload__distribution') \
        .annotate(latest_upload=Max('packageupload__uploaded')) \
        .filter(latest_upload__lt=expiration_date)

    uploads = set()

    for package in packages:
        uploads.update(PackageUpload.objects.filter(
            package__name=package['name'],
            distribution=package['packageupload__distribution']))

    removals = remove_uploads(uploads)
117
118
119
    notify_uploaders(removals, reason='Your package found no sponsor for '
                                      '20 weeks')

120

121
def notify_uploaders(removals, reason):
122
123
124
125
126
127
    for package, distribution, uploader in removals:
        email = Email('email-upload-removed.html')
        email.send(f'{package} has been removed from '
                   f'{distribution} on {settings.SITE_NAME}',
                   recipients=[uploader.email], package=package,
                   distribution=distribution,
128
                   reason=reason)
129
130


131
@shared_task
132
def remove_uploaded_packages(client=None):
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
    uploads_to_archive = set()
    uploads_to_new = set()

    uploads_to_archive.update(get_packages_uploaded_to_archive(client))
    uploads_to_new.update(get_packages_uploaded_to_new())

    removals_from_archive = remove_uploads(uploads_to_archive)
    removals_from_new = remove_uploads(uploads_to_new)

    mark_packages_as_uploaded(set([removal[0] for removal in
                                   removals_from_archive]), debian=True)
    mark_packages_as_uploaded(set([removal[0] for removal in
                                   removals_from_new]))

    notify_uploaders(removals_from_archive,
                     reason='Your package was uploaded to the '
                            'official Debian archive')
    notify_uploaders(removals_from_new,
                     reason='Your package was uploaded to the '
                            'NEW queue')


def get_packages_uploaded_to_new():
156
    uploads = set()
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
    ftp_master = ClientFTPMaster()

    try:
        packages = ftp_master.get_packages_uploaded_to_new()
    except ExceptionClient as e:
        log.warning(f'Could not retrive package uploaded to new: {e}')
        return []

    for changes in packages:
        try:
            uploads.update(process_accepted_changes(changes))
        except Exception as e:
            log.warning(f'Failed to process package in NEW {changes} '
                        f'{e}')

    return uploads


def get_packages_uploaded_to_archive(client):
    uploads = set()
    feeds = NNTPFeed.objects.filter(namespace='remove_uploads')
178
179
180
181
182
183
184
185

    # We allow the caller to define another NNTPClient, if not fallback to the
    # default one. This is for testing purposes only: easier than setting up a
    # testing NNTP server.
    if not client:  # pragma: no cover
        client = NNTPClient()

    if not client.connect_to_server():
186
        return []
187
188
189
190
191
192

    for feed in feeds:
        last = feed.last

        for msg in client.unread_messages(feed.name, feed.last):
            try:
193
194
                changes = convert_mail_to_changes(msg)
                uploads.update(process_accepted_changes(changes))
195
196
197
            except Exception as e:
                log.warning('Failed to process message after '
                            f'#{last} on '
198
                            f'{feed.name}: {e}')
199
200
201
202
203
204
205
206
207
208
209
            else:
                last = msg['X-Debexpo-Message-Number']

        feed.last = last

    client.disconnect_from_server()

    for feed in feeds:
        feed.full_clean()
        feed.save()

210
211
    return uploads

212

213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def mark_packages_as_uploaded(packages, debian=False):
    for name in packages:
        try:
            package = Package.objects.get(name=name)
        except Package.DoesNotExist:
            continue

        if debian:
            package.in_debian = True
        package.needs_sponsor = False
        package.full_clean()
        package.save()


def convert_mail_to_changes(mail):
228
    if not mail:
229
230
        return

231
232
233
234
235
    if mail.is_multipart():
        changes = mail.get_payload()[0].get_payload(decode=True)
    else:
        changes = mail.get_payload(decode=True)

236
237
    changes = Changes(changes)

238
239
240
241
242
243
    return changes


def process_accepted_changes(changes):
    if not changes or \
            'Source' not in changes \
244
245
            or 'Distribution' not in changes \
            or 'Version' not in changes:
246
        raise Exception(f'Cannot process accepted upload: {changes}')
247
248

    uploads = PackageUpload.objects.filter(
249
250
251
252
253
254
255
256
        Q(
            package__name=changes['Source'],
            distribution__name__in=(changes['Distribution'], 'UNRELEASED',)
        ) | Q(
            package__name=changes['Source'],
            version=changes['Version']
        )
    )
257

258
259
260
    version = max([NativeVersion(version) for version in
                   changes['Version'].split(' ')])

261
262
    return [
        upload for upload in uploads
263
        if NativeVersion(upload.version) <= NativeVersion(version) or
264
        upload.distribution.name == 'UNRELEASED'
265
    ]