views.py 29.4 KB
Newer Older
Enrico Zini's avatar
Enrico Zini committed
1
from __future__ import annotations
Enrico Zini's avatar
Enrico Zini committed
2
from django.utils.translation import ugettext as _
Enrico Zini's avatar
Enrico Zini committed
3
4
5
6
from django.shortcuts import redirect, get_object_or_404
from django.views.generic import TemplateView, View
from django.views.generic.edit import FormView
from django.utils.timezone import now
urbec's avatar
urbec committed
7
import django.utils.translation as translation
Enrico Zini's avatar
Enrico Zini committed
8
from django.db import transaction
9
from django import forms, http
10
from django.core.exceptions import PermissionDenied
Enrico Zini's avatar
Enrico Zini committed
11
12
from django.contrib import messages as django_messages
from django.urls import reverse
13
from rest_framework import viewsets
14
from backend.shortcuts import build_absolute_uri
15
from backend.mixins import VisitorMixin, VisitPersonMixin, TokenAuthMixin
16
from backend import const
Enrico Zini's avatar
Enrico Zini committed
17
import backend.models as bmodels
Enrico Zini's avatar
Enrico Zini committed
18
from nm2.lib import assets
19
import nm2.lib.forms
20
from .mixins import VisitProcessMixin, RequirementMixin, StatementMixin
Enrico Zini's avatar
Enrico Zini committed
21
import datetime
22
import requests
23
from six.moves import shlex_quote
Enrico Zini's avatar
Enrico Zini committed
24
from . import models as pmodels
25
from .forms import StatementForm
26
from .serializers import ProcessSerializer
27
from . import ops as pops
28
29


Enrico Zini's avatar
Enrico Zini committed
30
31
32
33
34
# Catcher for HTTPError
def _catch_http_error(request, e, ret_dest):
    django_messages.add_message(
        request,
        django_messages.ERROR,
35
        _("The server encountered an error: {}").format(e),
Enrico Zini's avatar
Enrico Zini committed
36
37
38
39
    )
    return redirect(ret_dest)


40
41
42
43
class ProcessViewSet(viewsets.ReadOnlyModelViewSet):
    """
    Export process information
    """
Enrico Zini's avatar
Enrico Zini committed
44
45
    queryset = pmodels.Process.objects.filter(
        closed_time__isnull=True).order_by("started")
46
    serializer_class = ProcessSerializer
47
48


Enrico Zini's avatar
Enrico Zini committed
49
50
51
52
class List(VisitorMixin, TemplateView):
    """
    List active and recently closed processes
    """
Enrico Zini's avatar
Enrico Zini committed
53
    assets = [assets.DataTablesBootstrap4]
Enrico Zini's avatar
Enrico Zini committed
54
55
56
57
    template_name = "process/list.html"

    def get_context_data(self, **kw):
        ctx = super(List, self).get_context_data(**kw)
Enrico Zini's avatar
Enrico Zini committed
58
59
        ctx["current"] = pmodels.Process.objects.filter(
            closed_time__isnull=True).order_by("applying_for").select_related("person")
Enrico Zini's avatar
Enrico Zini committed
60
        cutoff = now() - datetime.timedelta(days=30)
Enrico Zini's avatar
Enrico Zini committed
61
62
        ctx["last"] = pmodels.Process.objects.filter(
            closed_time__gte=cutoff).order_by("-closed_time").select_related("person")
Enrico Zini's avatar
Enrico Zini committed
63
64
65
        return ctx


66
67
68
69
70
71
72
73
74
class AMDashboard(VisitorMixin, TemplateView):
    require_visitor = "am"
    template_name = "process/amdashboard.html"

    def _show_process(self, process):
        """
        Return True if the process should be shown in the dashboard, False if
        it should not.
        """
Enrico Zini's avatar
Enrico Zini committed
75
76
        if process.frozen_by_id is not None or process.approved_by_id is not None:
            return True
77

Enrico Zini's avatar
Enrico Zini committed
78
79
        if process.hide_until and process.hide_until > now():
            return False
80

Enrico Zini's avatar
Enrico Zini committed
81
82
        if process.applying_for in (const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD):
            return True
83

Enrico Zini's avatar
Enrico Zini committed
84
85
        for_ga = process.applying_for in (
            const.STATUS_DC_GA, const.STATUS_DM_GA)
86
87
88

        for req in process.requirements.all():
            if req.type == "intent":
Enrico Zini's avatar
Enrico Zini committed
89
90
                if not req.approved_by_id:
                    return False
91
92
                # Hide all processes with a statement of intent approved less
                # than 4 days ago
Enrico Zini's avatar
Enrico Zini committed
93
94
                if not for_ga and req.approved_time + datetime.timedelta(days=4) > now():
                    return False
Mattia Rizzolo's avatar
Mattia Rizzolo committed
95
96
            if req.type in ("sc_dmup", "advocate", "keycheck") and not req.approved_by_id:
                return True
Enrico Zini's avatar
Enrico Zini committed
97
            if req.type == "am_ok":
Mattia Rizzolo's avatar
Mattia Rizzolo committed
98
99
                if req.approved_by_id is None and process.current_am_assignment:
                    return False
100
101
102
103
104
105
106

        return True

    def get_context_data(self, **kw):
        ctx = super().get_context_data(**kw)

        import process.models as pmodels
Enrico Zini's avatar
Enrico Zini committed
107
        from sitechecks.models import Inconsistency
108
109
        processes = []
        approved_processes = []
110
        fd_approved_processes = []
111
        for p in pmodels.Process.objects.filter(closed_time__isnull=True).order_by("applying_for"):
112
113
            # Shows in fd_approved_processes even hidden processes, so DAM can't miss these
            if p.applying_for in ['dd_nu', 'dd_u']:
114
                if p.approved and p.approved_by.am_or_none and not p.approved_by.am.is_dam and not process.rt_ticket:
115
                    fd_approved_processes.append(p)
116
                    continue
Enrico Zini's avatar
Enrico Zini committed
117
118
            if not self._show_process(p):
                continue
119
120
121
122
123
124
            if p.approved:
                approved_processes.append(p)
            else:
                processes.append(p)
        ctx["current_processes"] = processes
        ctx["approved_processes"] = approved_processes
125
        ctx["fd_approved_processes"] = fd_approved_processes
126
127
128

        ctx["am_available"] = bmodels.AM.list_available(free_only=True)

Enrico Zini's avatar
Enrico Zini committed
129
130
131
132
133
134
135
136
        today = now().date()
        ctx["inconsistencies"] = [
            i for i in Inconsistency.objects.filter(last_seen__gte=today)
                                            .order_by("-last_seen", "person", "process", "tag", "text")
            if (i.ignore_until is None or i.ignore_until < today)
        ]

        for a in pmodels.AMAssignment.objects.filter(
137
                            am=self.request.user.am, process__closed_time__isnull=True, unassigned_by__isnull=True) \
138
139
140
141
142
143
144
145
146
147
                        .select_related("process") \
                        .order_by("process__started"):
            if a.paused:
                ctx.setdefault("am_prog_hold", []).append(a.process)
            else:
                ctx.setdefault("am_prog_am", []).append(a.process)

        return ctx


148
class Create(VisitPersonMixin, FormView):
Enrico Zini's avatar
Enrico Zini committed
149
150
151
    """
    Create a new process
    """
Enrico Zini's avatar
Enrico Zini committed
152
    require_visit_perms = "request_new_status"
153
154
    template_name = "process/create.html"

Enrico Zini's avatar
Enrico Zini committed
155
156
157
    def get_context_data(self, **kw):
        ctx = super(Create, self).get_context_data(**kw)
        current = []
Enrico Zini's avatar
Enrico Zini committed
158
159
        current.extend(pmodels.Process.objects.filter(
            person=self.person, closed_time__isnull=True))
Enrico Zini's avatar
Enrico Zini committed
160
        ctx["current"] = current
161
        ctx["wikihelp"] = "https://wiki.debian.org/DebianDeveloper/JoinTheProject/NewMember/StatusChangeStep"
Enrico Zini's avatar
Enrico Zini committed
162
163
        return ctx

164
165
    def get_form_class(self):
        whitelist = self.person.possible_new_statuses
Enrico Zini's avatar
Enrico Zini committed
166
167
168
169
170
        choices = [(x.tag, x.ldesc)
                   for x in const.ALL_STATUS if x.tag in whitelist]
        if not choices:
            raise PermissionDenied

171
        class Form(nm2.lib.forms.BootstrapAttrsMixin, forms.Form):
Enrico Zini's avatar
Enrico Zini committed
172
173
            applying_for = forms.ChoiceField(
                label=_("Apply for status"), choices=choices, required=True)
174
175
176
177
        return Form

    def form_valid(self, form):
        applying_for = form.cleaned_data["applying_for"]
178
179
180
        if applying_for == const.STATUS_EMERITUS_DD:
            return redirect(reverse("process_emeritus", args=[self.person.lookup_key]))

Enrico Zini's avatar
Enrico Zini committed
181
        op = pops.ProcessCreate(
182
            person=self.person, applying_for=applying_for, audit_author=self.request.user)
183
        op.execute(self.request)
184

185
        return redirect(op.new_process.get_absolute_url())
Enrico Zini's avatar
Enrico Zini committed
186
187
188


class Show(VisitProcessMixin, TemplateView):
189
190
191
192
193
    """
    Show a process
    """
    template_name = "process/show.html"

194
195
196
    def get_context_data(self, **kw):
        ctx = super(Show, self).get_context_data(**kw)
        ctx["status"] = self.compute_process_status()
197
        ctx["picture"] = self.person.get_picture_url()
198
199
        return ctx

200

201
202
203
204
class AddProcessLog(VisitProcessMixin, View):
    """
    Add an entry to the process or requirement log
    """
Enrico Zini's avatar
Enrico Zini committed
205
    @transaction.atomic
206
    def post(self, request, *args, **kw):
Enrico Zini's avatar
Enrico Zini committed
207
        logtext = request.POST.get("logtext", "").strip()
Enrico Zini's avatar
Enrico Zini committed
208
        action = request.POST.get("add_action", "undefined")
209
        req_type = request.POST.get("req_type", None)
Enrico Zini's avatar
Enrico Zini committed
210

211
        if req_type:
Enrico Zini's avatar
Enrico Zini committed
212
213
            requirement = get_object_or_404(
                pmodels.Requirement, process=self.process, type=req_type)
Enrico Zini's avatar
Enrico Zini committed
214
            target = requirement
215
        else:
Enrico Zini's avatar
Enrico Zini committed
216
            requirement = None
217
218
            target = self.process

219
        visit_perms = target.permissions_of(self.request.user)
220

Enrico Zini's avatar
Enrico Zini committed
221
222
        op = None
        if action in ("log_private", "log_public"):
Enrico Zini's avatar
Enrico Zini committed
223
224
            if "add_log" not in visit_perms:
                raise PermissionDenied
Enrico Zini's avatar
Enrico Zini committed
225
226
227
228
229
230
            if logtext:
                op_args = {}
                if req_type:
                    op_args["requirement"] = requirement
                else:
                    op_args["process"] = self.process
231
                op = pops.ProcessAddLogEntry(
232
                    audit_author=self.request.user,
Enrico Zini's avatar
Enrico Zini committed
233
234
235
                    audit_notes=logtext,
                    is_public=action == "log_public",
                    **op_args)
Enrico Zini's avatar
Enrico Zini committed
236
        elif action == "req_unapprove":
Enrico Zini's avatar
Enrico Zini committed
237
238
239
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.RequirementUnapprove(
240
241
                audit_author=self.request.user, audit_notes=logtext or "Requirement unapproved",
                requirement=requirement)
Enrico Zini's avatar
Enrico Zini committed
242
        elif action == "req_approve":
Enrico Zini's avatar
Enrico Zini committed
243
244
245
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.RequirementApprove(
246
                audit_author=self.request.user, audit_notes=logtext or "Requirement approved", requirement=requirement)
Enrico Zini's avatar
Enrico Zini committed
247
248
249
250
        elif action == "proc_pause":
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.ProcessPause(
251
                audit_author=self.request.user, audit_notes=logtext or "Process paused", process=self.process)
Enrico Zini's avatar
Enrico Zini committed
252
253
254
255
        elif action == "proc_unpause":
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.ProcessUnpause(
256
                audit_author=self.request.user, audit_notes=logtext or "Process resumed", process=self.process)
Enrico Zini's avatar
Enrico Zini committed
257
        elif action == "proc_freeze":
Enrico Zini's avatar
Enrico Zini committed
258
259
260
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.ProcessFreeze(
261
262
                audit_author=self.request.user, audit_notes=logtext or "Process frozen for review",
                process=self.process)
Enrico Zini's avatar
Enrico Zini committed
263
        elif action == "proc_unfreeze":
Enrico Zini's avatar
Enrico Zini committed
264
265
266
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.ProcessUnfreeze(
267
                audit_author=self.request.user,
Enrico Zini's avatar
Enrico Zini committed
268
269
270
271
272
                audit_notes=logtext or "Process unfrozen for further work",
                process=self.process)
        elif action == "proc_approve_form":
            if "proc_approve" not in visit_perms:
                raise PermissionDenied
273
            return redirect("process_statement_create", pk=self.process.pk, type="approval")
Enrico Zini's avatar
Enrico Zini committed
274
        elif action == "proc_approve":
Enrico Zini's avatar
Enrico Zini committed
275
276
277
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.ProcessApprove(
278
                audit_author=self.request.user, audit_notes=logtext or "Process approved", process=self.process)
Enrico Zini's avatar
Enrico Zini committed
279
        elif action == "proc_unapprove":
Enrico Zini's avatar
Enrico Zini committed
280
281
282
            if action not in visit_perms:
                raise PermissionDenied
            op = pops.ProcessUnapprove(
283
                audit_author=self.request.user, audit_notes=logtext or "Process unapproved", process=self.process)
Enrico Zini's avatar
Enrico Zini committed
284
285

        if op is not None:
286
            op.execute(self.request)
287

288
289
290
        return redirect(target.get_absolute_url())


291
292
293
class ReqIntent(RequirementMixin, TemplateView):
    type = "intent"
    template_name = "process/req_intent.html"
294
    require_visit_perms = "req_view"
295

296

297
298
299
300
301
class ReqAgreements(RequirementMixin, TemplateView):
    type = "sc_dmup"
    template_name = "process/req_sc_dmup.html"


Enrico Zini's avatar
Enrico Zini committed
302
303
304
305
class ReqKeycheck(RequirementMixin, TemplateView):
    type = "keycheck"
    template_name = "process/req_keycheck.html"

306
307
308
309
310
311
312
    def get_context_data(self, **kw):
        ctx = super(ReqKeycheck, self).get_context_data(**kw)
        if self.process.person.fingerprint:
            ctx["endorsements"] = self.process.person.fingerprint.received_endorsements.order_by('-date')[:10]

        return ctx

Enrico Zini's avatar
Enrico Zini committed
313
314
315
316
317
318
319
320
    def get(self, request, *args, **kw):
        ret_dest = self.requirement.get_absolute_url()

        try:
            return super(ReqKeycheck, self).get(request, *args, **kw)
        except requests.HTTPError as e:
            return _catch_http_error(request, e, ret_dest)

Enrico Zini's avatar
Enrico Zini committed
321

Enrico Zini's avatar
Enrico Zini committed
322
323
324
325
326
327
class ReqAdvocate(RequirementMixin, TemplateView):
    type = "advocate"
    template_name = "process/req_advocate.html"

    def get_context_data(self, **kw):
        ctx = super(ReqAdvocate, self).get_context_data(**kw)
Enrico Zini's avatar
Enrico Zini committed
328
329
330
331
332
        ctx["warn_dm_preferred"] = (
                self.process.applying_for == const.STATUS_DD_U and
                self.process.person.status in (
                    const.STATUS_DC, const.STATUS_DC_GA,
                ))
Enrico Zini's avatar
Enrico Zini committed
333
        return ctx
334

Enrico Zini's avatar
Enrico Zini committed
335
336
337
338
339
340

class ReqAM(RequirementMixin, TemplateView):
    type = "am_ok"
    template_name = "process/req_am_ok.html"


341
342
343
class ReqApproval(RequirementMixin, TemplateView):
    type = "approval"
    template_name = "process/req_approval.html"
344
    require_visit_perms = "req_view"
345
346


Enrico Zini's avatar
Enrico Zini committed
347
class AssignAM(RequirementMixin, TemplateView):
348
    assets = [assets.DataTablesBootstrap4]
Enrico Zini's avatar
Enrico Zini committed
349
    require_visit_perms = "am_assign"
Enrico Zini's avatar
Enrico Zini committed
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
    type = "am_ok"
    template_name = "process/assign_am.html"

    def pre_dispatch(self):
        super(AssignAM, self).pre_dispatch()
        if self.process.current_am_assignment is not None:
            raise PermissionDenied

    def get_context_data(self, **kw):
        ctx = super(AssignAM, self).get_context_data(**kw)
        ctx["ams"] = bmodels.AM.list_available(free_only=False)
        return ctx

    def post(self, request, *args, **kw):
        am_key = request.POST.get("am", None)
        am = bmodels.AM.lookup_or_404(am_key)
Enrico Zini's avatar
Enrico Zini committed
366
        op = pops.ProcessAssignAM(
367
            audit_author=self.request.user, process=self.process, am=am)
368
        op.execute(self.request)
Enrico Zini's avatar
Enrico Zini committed
369
370
371
372
        return redirect(self.requirement.get_absolute_url())


class UnassignAM(RequirementMixin, View):
Enrico Zini's avatar
Enrico Zini committed
373
    require_visit_perms = "am_unassign"
Enrico Zini's avatar
Enrico Zini committed
374
    type = "am_ok"
Enrico Zini's avatar
Enrico Zini committed
375

Enrico Zini's avatar
Enrico Zini committed
376
377
378
    def post(self, request, *args, **kw):
        current = self.process.current_am_assignment
        if current is not None:
Enrico Zini's avatar
Enrico Zini committed
379
            op = pops.ProcessUnassignAM(
380
                audit_author=self.request.user, assignment=current)
381
            op.execute(self.request)
Enrico Zini's avatar
Enrico Zini committed
382
        return redirect(self.requirement.get_absolute_url())
Enrico Zini's avatar
Enrico Zini committed
383

384

Enrico Zini's avatar
Enrico Zini committed
385
class StatementCreate(RequirementMixin, FormView):
386
    form_class = StatementForm
Enrico Zini's avatar
Enrico Zini committed
387
    require_visit_perms = "edit_statements"
388
    template_name = "process/statement_create.html"
389

390
    def load_objects(self):
391
        super().load_objects()
392
393
        self.blurb = self.get_blurb()
        if self.blurb:
Enrico Zini's avatar
Enrico Zini committed
394
395
            self.blurb = [
                "For nm.debian.org, at {:%Y-%m-%d}:".format(now())] + self.blurb
Enrico Zini's avatar
Enrico Zini committed
396
397
398
399

    def check_permissions(self):
        super().check_permissions()
        if self.requirement.process.applying_for in (const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD):
Pierre-Elliott Bécue's avatar
Pierre-Elliott Bécue committed
400
401
            if self.requirement.type != "approval":
                raise PermissionDenied
Enrico Zini's avatar
Enrico Zini committed
402
403
404

    def get_form_kwargs(self):
        kw = super().get_form_kwargs()
405
        kw["fpr"] = self.request.user.fpr
Enrico Zini's avatar
Enrico Zini committed
406
        return kw
407

408
409
410
411
412
413
    def get_blurb(self):
        """
        Get the blurb used for auto-verification, or None if none is available
        """
        if self.requirement.type == "sc_dmup":
            return [
Enrico Zini's avatar
Enrico Zini committed
414
415
                "I agree to uphold the Social Contract, the Debian Free Software Guidelines,",
                "and the Debian Code of Conduct, in my Debian work.",
416
                "I have read the Debian Machine Usage Policies and I accept them."
417
418
419
            ]
        return None

420
421
422
423
424
425
    def approval_get_rt_ticket(self):
        """
        Gets the content of the RT ticket in case of approval requirement
        """
        return make_rt_ticket_text(self.request, self.request.user, self.process)

426
    def check_rt_now(self):
427
        am = self.request.user.am_or_none
428
429
430
431
432
        not_rt_now = (
            const.STATUS_DD_U,
            const.STATUS_DD_NU,
        )

433
        if am.is_dam:
434
            return True
435
        elif self.requirement.process.applying_for not in not_rt_now:
436
437
438
439
440
441
442
443
444
            return True
        return False

    def handle_approval(self, statement):
        """
        Handles all the consequences of an approval correctly made
        """

        logtext = self.request.POST.get("logtext", "").strip()
445

446
        if self.check_rt_now():
447
            op = pops.ProcessApproveRT(
448
449
450
451
                process=self.process,
                audit_author=self.request.user,
                audit_notes=logtext,
            )
452
453
454
455
456
457
458
459
460
461
462
463
464
            op.rt_text = statement
            try:
                op.execute(self.request)
            except op.RTError as e:
                out = http.HttpResponse(content_type="text/plain")
                out.status_code = 500
                print("Error:", e.msg, file=out)
                print("RT response:", file=out)
                for line in e.rt_lines:
                    print(line, file=out)
                return out
        else:
            op = pops.ProcessApprove(
465
                audit_author=self.request.user, audit_notes=logtext, process=self.process)
466
467
            op.execute(self.request)

468
    def get_context_data(self, **kw):
469
        ctx = super().get_context_data(**kw)
Enrico Zini's avatar
Enrico Zini committed
470
471
        ctx["blurb"] = [shlex_quote(x)
                        for x in self.blurb] if self.blurb else None
472
473
        if self.requirement.type == "approval":
            ctx["rt_ticket_content"] = self.approval_get_rt_ticket()
474
475
476
477
        return ctx

    @transaction.atomic
    def form_valid(self, form):
Enrico Zini's avatar
Enrico Zini committed
478
479
        statement, plaintext = form.cleaned_data["statement"]
        op = pops.ProcessStatementAdd(
480
            audit_author=self.request.user,
Enrico Zini's avatar
Enrico Zini committed
481
482
            requirement=self.requirement,
            statement=statement)
Enrico Zini's avatar
Enrico Zini committed
483
        op.execute(self.request)
484
485
        if self.requirement.type == "approval":
            self.handle_approval(statement)
486
            return redirect(self.process.get_absolute_url())
487
        return redirect(self.requirement.get_absolute_url())
488

489

490
class StatementDelete(StatementMixin, TemplateView):
491
492
493
494
    # FIXME: it was 'edit_statements', which was too broad. 'fd_comments' is a
    # mitigation, ideally it should be "FD, DAM, and the person who signed the
    # statement
    require_visit_perms = "fd_comments"
495
    template_name = "process/statement_delete.html"
496

497
    def post(self, request, *args, **kw):
Enrico Zini's avatar
Enrico Zini committed
498
        op = pops.ProcessStatementRemove(
499
            audit_author=self.request.user, statement=self.statement)
Enrico Zini's avatar
Enrico Zini committed
500
        op.execute(self.request)
501
        return redirect(self.requirement.get_absolute_url())
502
503


504
class StatementRaw(StatementMixin, View):
505
506
507
508
    def get(self, request, *args, **kw):
        return http.HttpResponse(self.statement.statement, content_type="text/plain")


509
class MailArchive(VisitProcessMixin, View):
Enrico Zini's avatar
Enrico Zini committed
510
    require_visit_perms = "view_mbox"
511

Enrico Zini's avatar
Enrico Zini committed
512
    def get(self, request, *args, **kw):
513
        fname = self.process.mailbox_file
Enrico Zini's avatar
Enrico Zini committed
514
515
        if fname is None:
            raise http.Http404
516

Enrico Zini's avatar
Enrico Zini committed
517
        user_fname = "{}-{}-{}.mbox".format(
Enrico Zini's avatar
Enrico Zini committed
518
            self.process.person.ldap_fields.uid or self.process.person.email,
Enrico Zini's avatar
Enrico Zini committed
519
            self.process.applying_for,
Enrico Zini's avatar
Enrico Zini committed
520
            self.process.pk)
521
522
523
524
525
526
527
528

        res = http.HttpResponse(content_type="application/octet-stream")
        res["Content-Disposition"] = "attachment; filename=%s.gz" % user_fname

        # Compress the mailbox and pass it to the request
        from gzip import GzipFile
        import shutil
        # The last mtime argument seems to only be supported in python 2.7
Enrico Zini's avatar
Enrico Zini committed
529
        outfd = GzipFile(user_fname, "wb", 9, res)  # , os.path.getmtime(fname))
530
        try:
531
            with open(fname, "rb") as infd:
532
                shutil.copyfileobj(infd, outfd)
533
            outfd.write(b"\n")
534
            outfd.write(self.process.get_statements_as_mbox(self.request.user))
535
536
537
538
539
540
        finally:
            outfd.close()
        return res


class DisplayMailArchive(VisitProcessMixin, TemplateView):
Enrico Zini's avatar
Enrico Zini committed
541
    require_visit_perms = "view_mbox"
Enrico Zini's avatar
Enrico Zini committed
542
    template_name = "process/display-mail-archive.html"
543
544

    def get_context_data(self, **kw):
Enrico Zini's avatar
Enrico Zini committed
545
        import backend.email
546
547
        ctx = super(DisplayMailArchive, self).get_context_data(**kw)
        fname = self.process.mailbox_file
Enrico Zini's avatar
Enrico Zini committed
548
549
        if fname is None:
            raise http.Http404
Enrico Zini's avatar
Enrico Zini committed
550
        ctx["mbox"] = backend.email.StoredEmail.get_mbox_jsonable(fname)
Enrico Zini's avatar
Enrico Zini committed
551
        ctx["process"] = self.process
552
553
        ctx["class"] = "clickable"
        return ctx
554
555
556
557
558
559
560
561


class UpdateKeycheck(RequirementMixin, View):
    type = "keycheck"
    require_visit_perms = "update_keycheck"

    def post(self, request, *args, **kw):
        from keyring.models import Key
Enrico Zini's avatar
Enrico Zini committed
562
563
564

        ret_dest = self.requirement.get_absolute_url()

565
566
        try:
            key = Key.objects.get_or_download(self.person.fpr)
Enrico Zini's avatar
Enrico Zini committed
567
        except RuntimeError:
568
            key = None
Enrico Zini's avatar
Enrico Zini committed
569
570
571
        except requests.HTTPError as e:
            return _catch_http_error(request, e, ret_dest)

572
        if key is not None:
Enrico Zini's avatar
Enrico Zini committed
573
574
575
576
577
            try:
                key.update_key()
            except requests.HTTPError as e:
                return _catch_http_error(request, e, ret_dest)

578
            key.update_check_sigs()
Enrico Zini's avatar
Enrico Zini committed
579
        return redirect(ret_dest)
580
581
582
583


class DownloadStatements(VisitProcessMixin, View):
    def get(self, request, *args, **kw):
584
        data = self.process.get_statements_as_mbox(self.request.user)
585
        res = http.HttpResponse(data, content_type="text/plain")
Enrico Zini's avatar
Enrico Zini committed
586
587
        res["Content-Disposition"] = "attachment; filename={}.mbox".format(
            self.person.lookup_key)
588
        return res
589
590


591
592
593
594
595
596
597
598
599
600
def only_needs_guest_account(process):
    if process.person.status == const.STATUS_DC:
        if process.applying_for == const.STATUS_DC_GA:
            return True
    elif process.person.status == const.STATUS_DM:
        if process.applying_for == const.STATUS_DM_GA:
            return True
    return False


Enrico Zini's avatar
Enrico Zini committed
601
def make_rt_ticket_text(request, visitor, process):
Enrico Zini's avatar
Enrico Zini committed
602
603
    retiring = process.applying_for in (
        const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD)
604
605
606
607
    ctx = {
        "visitor": visitor,
        "person": process.person,
        "process": process,
Enrico Zini's avatar
Enrico Zini committed
608
        "retiring": retiring,
609
610
    }

Enrico Zini's avatar
Enrico Zini committed
611
    # Build request text
612

urbec's avatar
urbec committed
613
614
    with translation.override('en'):
        req = []
615
        if process.person.status == const.STATUS_DC:
urbec's avatar
urbec committed
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
            if process.applying_for == const.STATUS_DC_GA:
                req.append(
                    "Please create a porter account for {person.fullname} (sponsored by {sponsors}).")
        elif process.person.status == const.STATUS_DC_GA:
            pass
        elif process.person.status == const.STATUS_DM:
            if process.applying_for == const.STATUS_DM_GA:
                req.append(
                    "Please create a porter account for {person.fullname} (currently a DM).")
        elif process.person.status == const.STATUS_DM_GA:
            pass
        elif process.person.status == const.STATUS_DD_NU:
            pass
        elif process.person.status == const.STATUS_EMERITUS_DD:
            pass
        elif process.person.status == const.STATUS_REMOVED_DD:
            pass

        only_guest_account = only_needs_guest_account(process)

        if retiring or (process.person.status == const.STATUS_DD_U and process.applying_for == const.STATUS_DD_NU):
Enrico Zini's avatar
Enrico Zini committed
637
            req.append(
urbec's avatar
urbec committed
638
639
                "Please make {person.fullname} (currently {status_with_pronoun}) {applying_for_with_pronoun}.")
        elif not only_guest_account:
Enrico Zini's avatar
Enrico Zini committed
640
            req.append(
urbec's avatar
urbec committed
641
642
                "Please make {person.fullname} (currently {status_with_pronoun}) "
                "{applying_for_with_pronoun} (advocated by {sponsors}).")
643

urbec's avatar
urbec committed
644
645
646
647
648
649
650
        if not only_guest_account:
            if process.person.status == const.STATUS_DC:
                req.append(
                    "Key {person.fpr} should be added to the '{applying_for}' keyring.")
            else:
                req.append(
                    "Key {person.fpr} should be moved from the '{status}' to the '{applying_for}' keyring.")
651

urbec's avatar
urbec committed
652
653
654
655
656
        if retiring:
            req.append("Please also disable the {person.ldap_fields.uid} LDAP account.")
        elif process.person.status not in (const.STATUS_DC, const.STATUS_DM):
            req.append(
                "Note that {person.fullname} already has an account in LDAP.")
Enrico Zini's avatar
Enrico Zini committed
657

urbec's avatar
urbec committed
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
        sponsors = set()
        try:
            adv_req = process.requirements.get(type="advocate")
        except pmodels.Requirement.DoesNotExist:
            adv_req = None
        if adv_req is not None:
            for st in adv_req.statements.all():
                sponsors.add(st.uploaded_by.lookup_key)
        sponsors = ", ".join(sorted(sponsors))

        format_args = {
            "person": process.person,
            "process": process,
            "status": const.ALL_STATUS_DESCS[process.person.status],
            "status_with_pronoun": const.ALL_STATUS_DESCS_WITH_PRONOUN[process.person.status],
            "applying_for": const.ALL_STATUS_DESCS[process.applying_for],
            "applying_for_with_pronoun": const.ALL_STATUS_DESCS_WITH_PRONOUN[process.applying_for],
            "sponsors": sponsors,
        }

        import textwrap
        wrapper = textwrap.TextWrapper(width=75)
        wrapped = []
        for paragraph in req:
            for line in wrapper.wrap(paragraph.format(**format_args)):
                wrapped.append(line)
            wrapped.append("")
        ctx["request"] = "\n".join(wrapped)

        # Format the declarations of intent

        wrapper = textwrap.TextWrapper(
            width=75, initial_indent="  ", subsequent_indent="  ")
        wrapped = []
        for intent in pmodels.Statement.objects.filter(requirement__process=process, requirement__type="intent"):
            wrapped.append("Details from {}:".format(intent.uploaded_by.ldap_fields.uid))
            wrapped.append("")
            for paragraph in intent.statement_clean.splitlines():
                for line in wrapper.wrap(paragraph):
                    wrapped.append(line)
            wrapped.append("")
        ctx["intents"] = "\n".join(wrapped)

        ctx["process_url"] = build_absolute_uri(
            process.get_absolute_url(), request)

        from django.template.loader import render_to_string
        return render_to_string("process/rt_ticket.txt", ctx).strip()
706
707


708
709
710
711
class EmeritusForm(forms.Form):
    statement = forms.CharField(
        required=True,
        label=_("Statement"),
Enrico Zini's avatar
Enrico Zini committed
712
        widget=forms.Textarea(attrs=dict(rows=10, cols=80)),
713
714
715
    )


716
class Emeritus(TokenAuthMixin, VisitPersonMixin, FormView):
717
718
719
720
    token_domain = "emeritus"
    require_visitor = "dd"
    template_name = "process/emeritus.html"
    form_class = EmeritusForm
Enrico Zini's avatar
Enrico Zini committed
721
722
723
    # Make the token last 3 months, so that one has plenty of time to use it
    # even if MIA lags triggering removal
    token_max_age = 90 * 3600 * 24
Enrico Zini's avatar
Enrico Zini committed
724
725
726
727
728
729
730
731
732
733
    initial = {
        "statement": """
Dear fellow developers,

As I am not currently active in Debian, I request to move to the Emeritus
status.

So long, and thanks for all the fish.
""".strip()
    }
734

735
736
737
    def load_objects(self):
        super().load_objects()
        try:
Enrico Zini's avatar
Enrico Zini committed
738
739
740
741
            self.process = pmodels.Process.objects.get(
                person=self.person,
                closed_by__isnull=True,
                applying_for__in=(const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD))
742
743
744
        except pmodels.Process.DoesNotExist:
            self.process = None

Enrico Zini's avatar
Enrico Zini committed
745
        self.expired = self.process is not None and self.process.approved
746

747
    def get_form_class(self):
748
        if not self.request.user.is_superuser:
Enrico Zini's avatar
Enrico Zini committed
749
750
            return super().get_form_class()

751
        class AdminForm(EmeritusForm):
Enrico Zini's avatar
Enrico Zini committed
752
753
            silent = forms.BooleanField(
                label=_("Do not mail debian-private"), required=False)
754
755
        return AdminForm

756
757
758
    def get_context_data(self, **kw):
        ctx = super().get_context_data(**kw)
        ctx["expired"] = self.expired
759
760
        return ctx

761
762
    @classmethod
    def get_nonauth_url(cls, person, request=None):
Enrico Zini's avatar
Enrico Zini committed
763
        if person.ldap_fields.uid is None:
Enrico Zini's avatar
Enrico Zini committed
764
765
            raise RuntimeError(
                "cannot generate an Emeritus url for a user without uid")
766
        url = reverse("process_emeritus_self") + "?" + cls.make_token(person.ldap_fields.uid)
Enrico Zini's avatar
Enrico Zini committed
767
768
        if not request:
            return url
769
        return build_absolute_uri(url, request)
770

771
    def form_valid(self, form):
772
773
774
        if self.expired:
            raise PermissionDenied

775
        op = pops.RequestEmeritus(
776
            audit_author=self.request.user,
777
778
779
780
            person=self.person,
            statement=form.cleaned_data["statement"],
            silent=form.cleaned_data.get("silent", False),
        )
Enrico Zini's avatar
Enrico Zini committed
781
        op.execute(self.request)
Enrico Zini's avatar
Enrico Zini committed
782

Enrico Zini's avatar
Enrico Zini committed
783
        return redirect(op._statement.requirement.process.get_absolute_url())
Enrico Zini's avatar
Enrico Zini committed
784
785


786
787
788
789
class CancelForm(forms.Form):
    statement = forms.CharField(
        required=True,
        label=_("Statement"),
Enrico Zini's avatar
Enrico Zini committed
790
        widget=forms.Textarea(attrs=dict(
791
            rows=25, cols=80, placeholder=_("Enter details of your activity in Debian")))
792
793
794
    )
    is_public = forms.BooleanField(
        required=False,
Enrico Zini's avatar
Enrico Zini committed
795
        label=_("Make the message public"),
796
797
798
799
800
801
802
803
804
    )


class Cancel(VisitProcessMixin, FormView):
    template_name = "process/cancel.html"
    form_class = CancelForm

    def check_permissions(self):
        super().check_permissions()
805
        # Visible by anonymous or by who can close the process
806
        if self.request.user.is_anonymous:
Enrico Zini's avatar
Enrico Zini committed
807
808
809
810
            if self.request.method == "GET":
                return
            else:
                raise PermissionDenied
811
812
813
814
        if "proc_close" not in self.visit_perms:
            raise PermissionDenied

    def form_valid(self, form):
Enrico Zini's avatar
Enrico Zini committed
815
816
817
818
819
820
        if self.process.applying_for in (const.STATUS_EMERITUS_DD, const.STATUS_REMOVED_DD):
            cls = pops.ProcessCancelEmeritus
        else:
            cls = pops.ProcessCancel

        op = cls(
821
            audit_author=self.request.user,
Enrico Zini's avatar
Enrico Zini committed
822
823
824
            process=self.process,
            is_public=form.cleaned_data["is_public"],
            statement=form.cleaned_data["statement"])
Enrico Zini's avatar
Enrico Zini committed
825
        op.execute(self.request)
826
        return redirect(self.process.get_absolute_url())