views.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916
  1. # coding: utf-8
  2. # nm.debian.org website reports
  3. #
  4. # Copyright (C) 2012--2014 Enrico Zini <enrico@debian.org>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as
  8. # published by the Free Software Foundation, either version 3 of the
  9. # License, or (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public License
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. from __future__ import print_function
  19. from __future__ import absolute_import
  20. from __future__ import division
  21. from __future__ import unicode_literals
  22. from django import http, forms
  23. from django.conf import settings
  24. from django.shortcuts import redirect, render, get_object_or_404
  25. from django.core.urlresolvers import reverse
  26. from django.core.exceptions import PermissionDenied
  27. from django.utils.translation import ugettext as _
  28. from django.utils.timezone import now
  29. from django.views.generic import TemplateView, View
  30. from django.views.generic.edit import FormView
  31. import backend.models as bmodels
  32. import backend.email as bemail
  33. from backend import const
  34. from backend.mixins import VisitorMixin, VisitorTemplateView, VisitPersonTemplateView, VisitProcessMixin, VisitProcessTemplateView
  35. from .email_stats import mailbox_get_gaps
  36. import markdown
  37. import datetime
  38. import os
  39. import json
  40. def lookup_or_404(dict, key):
  41. """
  42. Lookup a key in a dictionary, raising 404 if not found
  43. """
  44. try:
  45. return dict[key]
  46. except KeyError:
  47. raise http.Http404
  48. class Managers(VisitorTemplateView):
  49. template_name = "public/managers.html"
  50. def get_context_data(self, **kw):
  51. ctx = super(Managers, self).get_context_data(**kw)
  52. from django.db import connection
  53. # Compute statistics indexed by AM id
  54. cursor = connection.cursor()
  55. cursor.execute("""
  56. SELECT am.id,
  57. count(*) as total,
  58. sum(case when process.is_active then 1 else 0 end) as active,
  59. sum(case when process.progress=%s then 1 else 0 end) as held
  60. FROM am
  61. JOIN process ON process.manager_id=am.id
  62. GROUP BY am.id
  63. """, (const.PROGRESS_AM_HOLD,))
  64. stats = {}
  65. for amid, total, active, held in cursor:
  66. stats[amid] = (total, active, held)
  67. # Read the list of AMs, with default sorting, and annotate with the
  68. # statistics
  69. ams = []
  70. for a in bmodels.AM.objects.all().order_by("-is_am", "person__uid"):
  71. total, active, held = stats.get(a.id, (0, 0, 0))
  72. a.stats_total = total
  73. a.stats_active = active
  74. a.stats_done = total-active
  75. a.stats_held = held
  76. ams.append(a)
  77. ctx["ams"] = ams
  78. return ctx
  79. class Processes(VisitorTemplateView):
  80. template_name = "public/processes.html"
  81. def get_context_data(self, **kw):
  82. from django.db.models import Min, Max
  83. ctx = super(Processes, self).get_context_data(**kw)
  84. cutoff = now() - datetime.timedelta(days=180)
  85. ctx["open"] = bmodels.Process.objects.filter(is_active=True) \
  86. .annotate(
  87. started=Min("log__logdate"),
  88. last_change=Max("log__logdate")) \
  89. .order_by("-last_change")
  90. ctx["done"] = bmodels.Process.objects.filter(progress=const.PROGRESS_DONE) \
  91. .annotate(
  92. started=Min("log__logdate"),
  93. last_change=Max("log__logdate")) \
  94. .order_by("-last_change") \
  95. .filter(last_change__gt=cutoff)
  96. return ctx
  97. def make_statusupdateform(editor):
  98. if editor.is_fd:
  99. choices = [(x.tag, "%s - %s" % (x.tag, x.ldesc)) for x in const.ALL_PROGRESS]
  100. else:
  101. choices = [(x.tag, x.ldesc) for x in const.ALL_PROGRESS if x[0] in ("PROGRESS_APP_OK", "PROGRESS_AM", "PROGRESS_AM_HOLD", "PROGRESS_AM_OK")]
  102. class StatusUpdateForm(forms.Form):
  103. progress = forms.ChoiceField(
  104. required=True,
  105. label=_("Progress"),
  106. choices=choices
  107. )
  108. logtext = forms.CharField(
  109. required=False,
  110. label=_("Log text"),
  111. widget=forms.Textarea(attrs=dict(rows=5, cols=80))
  112. )
  113. log_is_public = forms.BooleanField(
  114. required=False,
  115. label=_("Log is public")
  116. )
  117. return StatusUpdateForm
  118. class Process(VisitProcessTemplateView):
  119. template_name = "public/process.html"
  120. def get_context_data(self, **kw):
  121. ctx = super(Process, self).get_context_data(**kw)
  122. # Process form ASAP, so we compute the rest with updated values
  123. am = self.visitor.am_or_none if self.visitor else None
  124. if am and (self.process.manager == am or am.is_admin) and (
  125. "edit_bio" in self.visit_perms or "edit_ldap" in self.visit_perms):
  126. StatusUpdateForm = make_statusupdateform(am)
  127. form = StatusUpdateForm(initial=dict(progress=self.process.progress))
  128. else:
  129. form = None
  130. ctx["form"] = form
  131. log = list(self.process.log.order_by("logdate", "progress"))
  132. if log:
  133. ctx["started"] = log[0].logdate
  134. ctx["last_change"] = log[-1].logdate
  135. else:
  136. ctx["started"] = datetime.datetime(1970, 1, 1, 0, 0, 0)
  137. ctx["last_change"] = datetime.datetime(1970, 1, 1, 0, 0, 0)
  138. if am:
  139. ctx["log"] = log
  140. else:
  141. # Summarise log for privacy
  142. distilled_log = []
  143. last_progress = None
  144. for l in log:
  145. if last_progress != l.progress:
  146. distilled_log.append(dict(
  147. progress=l.progress,
  148. changed_by=l.changed_by,
  149. logdate=l.logdate,
  150. ))
  151. last_progress = l.progress
  152. ctx["log"] = distilled_log
  153. # Map unusual steps to their previous usual ones
  154. unusual_step_map = {
  155. const.PROGRESS_APP_HOLD: const.PROGRESS_APP_RCVD,
  156. const.PROGRESS_AM_HOLD: const.PROGRESS_AM,
  157. const.PROGRESS_FD_HOLD: const.PROGRESS_AM_OK,
  158. const.PROGRESS_DAM_HOLD: const.PROGRESS_FD_OK,
  159. const.PROGRESS_CANCELLED: const.PROGRESS_DONE,
  160. }
  161. # Get the 'simplified' current step
  162. curstep = unusual_step_map.get(self.process.progress, self.process.progress)
  163. # List of usual steps in order
  164. steps = (
  165. const.PROGRESS_APP_NEW,
  166. const.PROGRESS_APP_RCVD,
  167. const.PROGRESS_ADV_RCVD,
  168. const.PROGRESS_POLL_SENT,
  169. const.PROGRESS_APP_OK,
  170. const.PROGRESS_AM_RCVD,
  171. const.PROGRESS_AM,
  172. const.PROGRESS_AM_OK,
  173. const.PROGRESS_FD_OK,
  174. const.PROGRESS_DAM_OK,
  175. const.PROGRESS_DONE,
  176. )
  177. # Add past/current/future timeline
  178. curstep_idx = steps.index(curstep)
  179. ctx["steps"] = steps
  180. ctx["curstep_idx"] = curstep_idx
  181. # Wizards for next actions
  182. if self.visitor:
  183. ctx["wizards"] = self.build_wizards(self.process)
  184. # Mailbox statistics
  185. # TODO: move saving per-process stats into a JSON field in Process
  186. try:
  187. with open(os.path.join(settings.DATA_DIR, 'mbox_stats.json'), "rt") as infd:
  188. stats = json.load(infd)
  189. except OSError:
  190. stats = {}
  191. stats = stats.get("process", {})
  192. stats = stats.get(self.process.lookup_key, {})
  193. if stats:
  194. stats["date_first_py"] = datetime.datetime.fromtimestamp(stats["date_first"])
  195. stats["date_last_py"] = datetime.datetime.fromtimestamp(stats["date_last"])
  196. if "median" not in stats or stats["median"] is None:
  197. stats["median_py"] = None
  198. else:
  199. stats["median_py"] = datetime.timedelta(seconds=stats["median"])
  200. stats["median_hours"] = stats["median_py"].seconds // 3600
  201. ctx["mbox_stats"] = stats
  202. # Key information for active processes
  203. if self.process.is_active and self.process.person.fpr:
  204. from keyring.models import Key
  205. try:
  206. key = Key.objects.get_or_download(self.process.person.fpr)
  207. except RuntimeError as e:
  208. key = None
  209. key_error = str(e)
  210. if key is not None:
  211. keycheck = key.keycheck()
  212. uids = []
  213. for ku in keycheck.uids:
  214. uids.append({
  215. "name": ku.uid.name.replace("@", ", "),
  216. "remarks": " ".join(sorted(ku.errors)) if ku.errors else "ok",
  217. "sigs_ok": len(ku.sigs_ok),
  218. "sigs_no_key": len(ku.sigs_no_key),
  219. "sigs_bad": len(ku.sigs_bad)
  220. })
  221. ctx["keycheck"] = {
  222. "main": {
  223. "remarks": " ".join(sorted(keycheck.errors)) if keycheck.errors else "ok",
  224. },
  225. "uids": uids,
  226. "updated": key.check_sigs_updated,
  227. }
  228. else:
  229. ctx["keycheck"] = {
  230. "main": {
  231. "remarks": key_error
  232. }
  233. }
  234. return ctx
  235. def build_wizards(self, process):
  236. wizards = []
  237. # TODO: add a wizard for free-form action
  238. # TODO: for each wizard, generate a form, which may or may not have a
  239. # "next status" field (generally not), and can have a default text in
  240. # the text area. Also, (pre)generate the template emails.
  241. if process.applying_for == const.STATUS_DC_GA or process.applying_for == const.STATUS_DM_GA:
  242. if self.visitor.is_admin and process.progress == const.PROGRESS_APP_NEW:
  243. wizards.append({
  244. "label": "Approve",
  245. "prog_to": const.PROGRESS_DAM_OK,
  246. "show_dam_mail": True,
  247. "mail_template": "dam",
  248. })
  249. if process.applying_for == const.STATUS_DD_U or process.applying_for == const.STATUS_DD_NU:
  250. if process.progress == const.PROGRESS_AM_RCVD:
  251. wizards.append({
  252. "label": "Confirm assignment",
  253. "prog_to": const.PROGRESS_AM,
  254. })
  255. if process.progress == const.PROGRESS_AM:
  256. wizards.append({
  257. "label": "ID check ok",
  258. "prog_to": const.PROGRESS_AM,
  259. "logtext": "ID check passed",
  260. })
  261. wizards.append({
  262. "label": "P&P ok",
  263. "prog_to": const.PROGRESS_AM,
  264. "logtext": "P&P check passed",
  265. })
  266. wizards.append({
  267. "label": "T&S ok",
  268. "prog_to": const.PROGRESS_AM,
  269. "logtext": "T&S check passed",
  270. })
  271. wizards.append({
  272. "label": "Approve applicant",
  273. "prog_to": const.PROGRESS_AM_OK,
  274. "logtext": "Please enter personal comment about applicant for the process log.\nMake sure all your communication with the NM has been Cc'ed or forwarded to the archive mailbox.\nOn submitting, the system will announce the approval in a summary message including the applicant's bio to nm@debian.org.",
  275. "mail_template": "am",
  276. })
  277. wizards.append({
  278. "label": "On hold",
  279. "prog_to": const.PROGRESS_AM_HOLD,
  280. "logtext": "Please enter reason for hold",
  281. })
  282. if process.progress == const.PROGRESS_AM_HOLD:
  283. wizards.append({
  284. "label": "Back from hold",
  285. "prog_to": const.PROGRESS_AM,
  286. })
  287. if process.progress in [const.PROGRESS_AM_RCVD, const.PROGRESS_AM, const.PROGRESS_AM_HOLD]:
  288. w = {
  289. "label": "Unassign",
  290. "prog_to": const.PROGRESS_APP_OK,
  291. }
  292. if self.visitor.is_admin:
  293. w["logtext"] = "Unassigned from {} [TODO: please enter a reason]".format(process.manager.person.uid)
  294. else:
  295. w["logtext"] = "Handing applicant back to Front Desk. [TODO: please enter a reason] [TODO: please send the mailbox with all your conversation so far to nm@debian.org]"
  296. wizards.append(w)
  297. if self.visitor.is_admin:
  298. if process.progress == const.PROGRESS_ADV_RCVD:
  299. wizards.append({
  300. "label": "Hold",
  301. "prog_to": const.PROGRESS_APP_HOLD,
  302. "logtext": "Please enter reason for hold",
  303. })
  304. wizards.append({
  305. "label": "Advocacies ok",
  306. "prog_to": const.PROGRESS_POLL_SENT,
  307. })
  308. if process.progress == const.PROGRESS_POLL_SENT:
  309. wizards.append({
  310. "label": "Activity poll answer received",
  311. "prog_to": const.PROGRESS_APP_OK,
  312. })
  313. if process.progress == const.PROGRESS_APP_HOLD:
  314. wizards.append({
  315. "label": "Unhold",
  316. "prog_to": const.PROGRESS_ADV_RCVD,
  317. })
  318. #TODO: assign AM, field with uid and macros to fill it with a list of free ones
  319. # ("PROGRESS_APP_OK", "app_ok", "Advocacies have been approved"),
  320. if process.progress == const.PROGRESS_AM_OK:
  321. wizards.append({
  322. "label": "FD hold",
  323. "prog_to": const.PROGRESS_FD_HOLD,
  324. "logtext": "Please enter reason for hold",
  325. })
  326. wizards.append({
  327. "label": "FD approve",
  328. "prog_to": const.PROGRESS_FD_OK,
  329. })
  330. if process.progress == const.PROGRESS_FD_OK:
  331. wizards.append({
  332. "label": "Unhold",
  333. "prog_to": const.PROGRESS_AM_OK,
  334. })
  335. if self.visitor.am.is_dam:
  336. if process.progress == const.PROGRESS_FD_OK:
  337. wizards.append({
  338. "label": "DAM hold",
  339. "prog_to": const.PROGRESS_DAM_HOLD,
  340. "logtext": "Please enter reason for hold",
  341. })
  342. wizards.append({
  343. "label": "DAM approve",
  344. "prog_to": const.PROGRESS_DAM_OK,
  345. "mail_template": "dam",
  346. })
  347. if process.progress == const.PROGRESS_DAM_HOLD:
  348. wizards.append({
  349. "label": "Unhold",
  350. "prog_to": const.PROGRESS_FD_OK,
  351. })
  352. return wizards
  353. def post(self, request, key, *args, **kw):
  354. if not self.visitor: raise PermissionDenied()
  355. am = self.visitor.am_or_none
  356. if not am: raise PermissionDenied
  357. StatusUpdateForm = make_statusupdateform(am)
  358. form = StatusUpdateForm(request.POST)
  359. if form.is_valid():
  360. if form.cleaned_data["progress"] == const.PROGRESS_APP_OK \
  361. and self.process.progress in [const.PROGRESS_AM_HOLD, const.PROGRESS_AM, const.PROGRESS_AM_RCVD]:
  362. # Unassign from AM
  363. self.process.manager = None
  364. self.process.progress = form.cleaned_data["progress"]
  365. self.process.save()
  366. text = form.cleaned_data["logtext"]
  367. if self.impersonator:
  368. text = "[%s as %s] %s" % (self.impersonator,
  369. self.visitor.lookup_key,
  370. text)
  371. log = bmodels.Log(
  372. changed_by=self.visitor,
  373. process=self.process,
  374. progress=self.process.progress,
  375. logtext=text,
  376. is_public=form.cleaned_data["log_is_public"]
  377. )
  378. log.save()
  379. form = StatusUpdateForm(initial=dict(progress=self.process.progress))
  380. context = self.get_context_data(**kw)
  381. return self.render_to_response(context)
  382. class ProcessUpdateKeycheck(VisitProcessMixin, View):
  383. require_visit_perms = "update_keycheck"
  384. def post(self, request, key, *args, **kw):
  385. from keyring.models import Key
  386. try:
  387. key = Key.objects.get_or_download(self.person.fpr)
  388. except RuntimeError as e:
  389. key = None
  390. if key is not None:
  391. key.update_key()
  392. key.update_check_sigs()
  393. return redirect(self.process.get_absolute_url())
  394. SIMPLIFY_STATUS = {
  395. const.STATUS_DC: "new",
  396. const.STATUS_DC_GA: "new",
  397. const.STATUS_DM: "dm",
  398. const.STATUS_DM_GA: "dm",
  399. const.STATUS_DD_U: "dd",
  400. const.STATUS_DD_NU: "dd",
  401. const.STATUS_EMERITUS_DD: "emeritus",
  402. const.STATUS_EMERITUS_DM: "emeritus",
  403. const.STATUS_REMOVED_DD: "removed",
  404. const.STATUS_REMOVED_DM: "removed",
  405. }
  406. class People(VisitorTemplateView):
  407. template_name = "public/people.html"
  408. def get_context_data(self, **kw):
  409. ctx = super(People, self).get_context_data(**kw)
  410. status = self.kwargs.get("status", None)
  411. #def people(request, status=None):
  412. objects = bmodels.Person.objects.all().order_by("uid", "sn", "cn")
  413. show_status = True
  414. status_sdesc = None
  415. status_ldesc = None
  416. if status:
  417. if status == "dm_all":
  418. objects = objects.filter(status__in=(const.STATUS_DM, const.STATUS_DM_GA))
  419. status_sdesc = _("Debian Maintainer")
  420. status_ldesc = _("Debian Maintainer (with or without guest account)")
  421. elif status == "dd_all":
  422. objects = objects.filter(status__in=(const.STATUS_DD_U, const.STATUS_DD_NU))
  423. status_sdesc = _("Debian Developer")
  424. status_ldesc = _("Debian Developer (uploading or not)")
  425. else:
  426. objects = objects.filter(status=status)
  427. show_status = False
  428. status_sdesc = lookup_or_404(const.ALL_STATUS_BYTAG, status).sdesc
  429. status_ldesc = lookup_or_404(const.ALL_STATUS_BYTAG, status).sdesc
  430. people = []
  431. for p in objects:
  432. p.simple_status = SIMPLIFY_STATUS.get(p.status, None)
  433. people.append(p)
  434. ctx.update(
  435. people=people,
  436. status=status,
  437. show_status=show_status,
  438. status_sdesc=status_sdesc,
  439. status_ldesc=status_ldesc,
  440. )
  441. return ctx
  442. class AuditLog(VisitorTemplateView):
  443. template_name = "public/audit_log.html"
  444. require_visitor = "dd"
  445. def get_context_data(self, **kw):
  446. ctx = super(AuditLog, self).get_context_data(**kw)
  447. audit_log = []
  448. is_admin = self.visitor.is_admin
  449. cutoff = now() - datetime.timedelta(days=30)
  450. for e in bmodels.PersonAuditLog.objects.filter(logdate__gte=cutoff).order_by("-logdate"):
  451. if is_admin:
  452. changes = sorted((k, v[0], v[1]) for k, v in json.loads(e.changes).items())
  453. else:
  454. changes = sorted((k, v[0], v[1]) for k, v in json.loads(e.changes).items() if k != "fd_comment")
  455. audit_log.append({
  456. "person": e.person,
  457. "logdate": e.logdate,
  458. "author": e.author,
  459. "notes": e.notes,
  460. "changes": changes,
  461. })
  462. ctx["audit_log"] = audit_log
  463. return ctx
  464. class Progress(VisitorTemplateView):
  465. template_name = "public/progress.html"
  466. def get_context_data(self, **kw):
  467. ctx = super(Progress, self).get_context_data(**kw)
  468. progress = self.kwargs["progress"]
  469. from django.db.models import Min, Max
  470. processes = bmodels.Process.objects.filter(progress=progress, is_active=True) \
  471. .annotate(started=Min("log__logdate"), ended=Max("log__logdate")) \
  472. .order_by("started")
  473. ctx.update(
  474. progress=progress,
  475. processes=processes,
  476. )
  477. return ctx
  478. class Stats(VisitorTemplateView):
  479. template_name = "public/stats.html"
  480. def get_context_data(self, **kw):
  481. ctx = super(Stats, self).get_context_data(**kw)
  482. from django.db.models import Count
  483. dtnow = now()
  484. stats = {}
  485. # Count of people by status
  486. by_status = dict()
  487. for row in bmodels.Person.objects.values("status").annotate(Count("status")):
  488. by_status[row["status"]] = row["status__count"]
  489. stats["by_status"] = by_status
  490. # Count of applicants by progress
  491. by_progress = dict()
  492. for row in bmodels.Process.objects.filter(is_active=True).values("progress").annotate(Count("progress")):
  493. by_progress[row["progress"]] = row["progress__count"]
  494. stats["by_progress"] = by_progress
  495. # Cook up more useful bits for the templates
  496. ctx["stats"] = stats
  497. status_table = []
  498. for status in (s.tag for s in const.ALL_STATUS):
  499. status_table.append((status, by_status.get(status, 0)))
  500. ctx["status_table"] = status_table
  501. ctx["status_table_json"] = json.dumps([(s.sdesc, by_status.get(s.tag, 0)) for s in const.ALL_STATUS])
  502. progress_table = []
  503. for progress in (s.tag for s in const.ALL_PROGRESS):
  504. progress_table.append((progress, by_progress.get(progress, 0)))
  505. ctx["progress_table"] = progress_table
  506. ctx["progress_table_json"] = json.dumps([(p.sdesc, by_progress.get(p.tag, 0)) for p in const.ALL_PROGRESS])
  507. # List of active processes with statistics
  508. active_processes = []
  509. for p in bmodels.Process.objects.filter(is_active=True):
  510. p.annotate_with_duration_stats()
  511. mbox_mtime = p.mailbox_mtime
  512. if mbox_mtime is None:
  513. p.mbox_age = None
  514. else:
  515. p.mbox_age = (dtnow - mbox_mtime).days
  516. active_processes.append(p)
  517. if self.visitor and self.visitor.is_admin:
  518. pathname = p.mailbox_file
  519. if pathname:
  520. p.mbox_stats = []
  521. for idx, (addr, length) in enumerate(mailbox_get_gaps(pathname)):
  522. neg = 1 if idx % 2 == 0 else -1
  523. p.mbox_stats.append(neg * min(round(length/86400), 30))
  524. else:
  525. p.mbox_stats = None
  526. active_processes.sort(key=lambda x:(x.log_first.logdate if x.log_first else None))
  527. ctx["active_processes"] = active_processes
  528. return ctx
  529. def get(self, request, *args, **kwargs):
  530. context = self.get_context_data(**kwargs)
  531. # If JSON is requested, dump them right away
  532. if 'json' in request.GET:
  533. res = http.HttpResponse(content_type="application/json")
  534. res["Content-Disposition"] = "attachment; filename=stats.json"
  535. json.dump(context["stats"], res, indent=1)
  536. return res
  537. else:
  538. return self.render_to_response(context)
  539. def make_findperson_form(request, visitor):
  540. includes = ["cn", "mn", "sn", "email", "uid", "status"]
  541. if visitor and visitor.is_admin:
  542. includes.append("username")
  543. includes.append("fd_comment")
  544. class FindpersonForm(forms.ModelForm):
  545. fpr = forms.CharField(label="Fingerprint", required=False, min_length=40, widget=forms.TextInput(attrs={"size": 60}))
  546. class Meta:
  547. model = bmodels.Person
  548. fields = includes
  549. def clean_fpr(self):
  550. return bmodels.FingerprintField.clean_fingerprint(self.cleaned_data['fpr'])
  551. return FindpersonForm
  552. class Findperson(VisitorMixin, FormView):
  553. template_name = "public/findperson.html"
  554. def get_form_class(self):
  555. return make_findperson_form(self.request, self.visitor)
  556. def form_valid(self, form):
  557. if not self.visitor or not self.visitor.is_admin:
  558. raise PermissionDenied()
  559. person = form.save(commit=False)
  560. person.save(audit_author=self.visitor, audit_notes="user created manually")
  561. fpr = form.cleaned_data["fpr"]
  562. if fpr:
  563. bmodels.Fingerprint.objects.create(fpr=fpr, person=person, is_active=True, audit_author=self.visitor, audit_notes="user created manually")
  564. return redirect(person.get_absolute_url())
  565. class StatsLatest(VisitorTemplateView):
  566. template_name = "public/stats_latest.html"
  567. def compute_stats(self):
  568. from django.db.models import Count, Min, Max
  569. days = int(self.request.GET.get("days", "7"))
  570. threshold = datetime.date.today() - datetime.timedelta(days=days)
  571. raw_counts = dict((x.tag, 0) for x in const.ALL_PROGRESS)
  572. for p in bmodels.Process.objects.values("progress").annotate(count=Count("id")).filter(is_active=True):
  573. raw_counts[p["progress"]] = p["count"]
  574. counts = dict(
  575. new=raw_counts[const.PROGRESS_APP_NEW] + raw_counts[const.PROGRESS_APP_RCVD] + raw_counts[const.PROGRESS_ADV_RCVD],
  576. new_hold=raw_counts[const.PROGRESS_APP_HOLD],
  577. new_ok=raw_counts[const.PROGRESS_APP_OK],
  578. am=raw_counts[const.PROGRESS_AM_RCVD] + raw_counts[const.PROGRESS_AM],
  579. am_hold=raw_counts[const.PROGRESS_AM_HOLD],
  580. fd=raw_counts[const.PROGRESS_AM_OK],
  581. fd_hold=raw_counts[const.PROGRESS_FD_HOLD],
  582. dam=raw_counts[const.PROGRESS_FD_OK],
  583. dam_hold=raw_counts[const.PROGRESS_DAM_HOLD],
  584. dam_ok=raw_counts[const.PROGRESS_DAM_OK],
  585. )
  586. irc_topic = "New %(new)d+%(new_hold)d ok %(new_ok)d | AM: %(am)d+%(am_hold)d | FD: %(fd)d+%(fd_hold)d | DAM: %(dam)d+%(dam_hold)d ok %(dam_ok)d" % counts
  587. events = []
  588. # Collect status change events
  589. for p in bmodels.Person.objects.filter(status_changed__gte=threshold).order_by("-status_changed"):
  590. events.append(dict(
  591. type="status",
  592. time=p.status_changed,
  593. person=p,
  594. ))
  595. # Collect progress change events
  596. for pr in bmodels.Process.objects.filter(is_active=True):
  597. old_progress = None
  598. for l in pr.log.order_by("logdate"):
  599. if l.progress != old_progress:
  600. if l.logdate.date() >= threshold:
  601. events.append(dict(
  602. type="progress",
  603. time=l.logdate,
  604. person=pr.person,
  605. log=l,
  606. ))
  607. old_progress = l.progress
  608. events.sort(key=lambda x:x["time"])
  609. return {
  610. "counts": counts,
  611. "raw_counts": raw_counts,
  612. "irc_topic": irc_topic,
  613. "events": events,
  614. }
  615. def get(self, request, *args, **kw):
  616. # If JSON is requested, dump them right away
  617. if 'json' in self.request.GET:
  618. ctx = self.compute_stats()
  619. json_evs = []
  620. for e in ctx["events"]:
  621. ne = dict(
  622. status_changed_dt=e["time"].strftime("%Y-%m-%d %H:%M:%S"),
  623. status_changed_ts=e["time"].strftime("%s"),
  624. uid=e["person"].uid,
  625. fn=e["person"].fullname,
  626. key=e["person"].lookup_key,
  627. type=e["type"],
  628. )
  629. if e["type"] == "status":
  630. ne.update(
  631. status=e["person"].status,
  632. )
  633. elif e["type"] == "progress":
  634. ne.update(
  635. process_key=e["log"].process.lookup_key,
  636. progress=e["log"].progress,
  637. )
  638. json_evs.append(ne)
  639. ctx["events"] = json_evs
  640. res = http.HttpResponse(content_type="application/json")
  641. res["Content-Disposition"] = "attachment; filename=stats.json"
  642. json.dump(ctx, res, indent=1)
  643. return res
  644. else:
  645. return super(StatsLatest, self).get(request, *args, **kw)
  646. def get_context_data(self, **kw):
  647. ctx = super(StatsLatest, self).get_context_data(**kw)
  648. ctx.update(**self.compute_stats())
  649. return ctx
  650. class StatsGraph(VisitorTemplateView):
  651. template_name = "public/stats_graph.html"
  652. def get_context_data(self, **kw):
  653. ctx = super(StatsGraph, self).get_context_data(**kw)
  654. from django.db import connection
  655. cursor = connection.cursor()
  656. cursor.execute("""
  657. SELECT am_person.uid AS am_uid, nm_person.uid AS nm_uid
  658. FROM person am_person
  659. JOIN am ON (am_person.id = am.person_id)
  660. JOIN process p ON (am.id = p.manager_id)
  661. JOIN person nm_person ON (p.person_id = nm_person.id);
  662. """)
  663. am_nm = []
  664. for am_uid, nm_uid in cursor:
  665. am_nm.append((am_uid, nm_uid))
  666. cursor = connection.cursor()
  667. cursor.execute("""
  668. SELECT adv_person.uid AS adv_uid, nm_person.uid AS nm_uid
  669. FROM person adv_person
  670. JOIN process_advocates adv ON (adv_person.id = adv.person_id)
  671. JOIN process p ON (adv.process_id = p.id)
  672. JOIN person nm_person ON (p.person_id = nm_person.id);
  673. """)
  674. adv_nm = []
  675. for adv_uid, nm_uid in cursor:
  676. adv_nm.append((adv_uid, nm_uid))
  677. ctx = dict(
  678. am_nm=am_nm,
  679. adv_nm=adv_nm,
  680. )
  681. return ctx
  682. YESNO = (
  683. ("yes", "Yes"),
  684. ("no", "No"),
  685. )
  686. class NewPersonForm(forms.ModelForm):
  687. fpr = forms.CharField(label="Fingerprint", min_length=40, widget=forms.TextInput(attrs={"size": 60}))
  688. sc_ok = forms.ChoiceField(choices=YESNO, widget=forms.RadioSelect(), label="SC and DFSG agreement")
  689. dmup_ok = forms.ChoiceField(choices=YESNO, widget=forms.RadioSelect(), label="DMUP agreement")
  690. def clean_fpr(self):
  691. data = bmodels.FingerprintField.clean_fingerprint(self.cleaned_data['fpr'])
  692. if bmodels.Fingerprint.objects.filter(fpr=data).exists():
  693. raise forms.ValidationError("The GPG fingerprint is already known to this system. Please contact Front Desk to link your Alioth account to it.")
  694. return data
  695. def clean_sc_ok(self):
  696. data = self.cleaned_data['sc_ok']
  697. if data != "yes":
  698. raise forms.ValidationError("You need to agree with the Debian Social Contract and DFSG to continue")
  699. return data
  700. def clean_dmup_ok(self):
  701. data = self.cleaned_data['dmup_ok']
  702. if data != "yes":
  703. raise forms.ValidationError("You need to agree with the DMUP to continue")
  704. return data
  705. class Meta:
  706. model = bmodels.Person
  707. fields = ["cn", "mn", "sn", "email", "bio", "uid"]
  708. widgets = {
  709. "bio": forms.Textarea(attrs={'cols': 80, 'rows': 25}),
  710. }
  711. class Newnm(VisitorMixin, FormView):
  712. """
  713. Display the new Person form
  714. """
  715. template_name = "public/newnm.html"
  716. form_class = NewPersonForm
  717. DAYS_VALID = 3
  718. def get_success_url(self):
  719. return redirect("public_newnm_resend_challenge", key=self.request.user.lookup_key)
  720. def form_valid(self, form):
  721. if self.visitor is not None: raise PermissionDenied
  722. if self.request.sso_username is None: raise PermissionDenied
  723. person = form.save(commit=False)
  724. person.username = self.request.sso_username
  725. person.status = const.STATUS_DC
  726. person.status_changed = now()
  727. person.make_pending(days_valid=self.DAYS_VALID)
  728. person.save(audit_author=person, audit_notes="new subscription to the site")
  729. fpr = form.cleaned_data["fpr"]
  730. bmodels.Fingerprint.objects.create(person=person, fpr=fpr, is_active=True, audit_author=person, audit_notes="new subscription to the site")
  731. # Redirect to the send challenge page
  732. return redirect("public_newnm_resend_challenge", key=person.lookup_key)
  733. def get_context_data(self, **kw):
  734. ctx = super(Newnm, self).get_context_data(**kw)
  735. form = ctx["form"]
  736. errors = []
  737. for k, v in form.errors.iteritems():
  738. if k in ("cn", "mn", "sn"):
  739. section = "name"
  740. elif k in ("sc_ok", "dmup_ok"):
  741. section = "rules"
  742. else:
  743. section = k
  744. errors.append({
  745. "section": section,
  746. "label": form.fields[k].label,
  747. "id": k,
  748. "errors": v,
  749. })
  750. has_entry = self.visitor is not None
  751. is_dd = self.visitor and "dd" in self.visitor.perms
  752. require_login = self.request.sso_username is None
  753. show_apply_form = not require_login and (not has_entry or is_dd)
  754. ctx.update(
  755. person=self.visitor,
  756. form=form,
  757. errors=errors,
  758. has_entry=has_entry,
  759. is_dd=is_dd,
  760. show_apply_form=show_apply_form,
  761. require_login=require_login,
  762. DAYS_VALID=self.DAYS_VALID,
  763. wikihelp="https://wiki.debian.org/nm.debian.org/Newnm",
  764. )
  765. return ctx
  766. class NewnmResendChallenge(VisitorMixin, View):
  767. """
  768. Send/resend the encrypted email nonce for people who just requested a new
  769. Person record
  770. """
  771. def get(self, request, key=None, *args, **kw):
  772. from keyring.models import Key
  773. if self.visitor is None: raise PermissionDenied()
  774. # Deal gracefully with someone clicking the reconfirm link after they have
  775. # already confirmed
  776. if not self.visitor.pending: return redirect(self.visitor.get_absolute_url())
  777. confirm_url = request.build_absolute_uri(reverse("public_newnm_confirm", kwargs=dict(nonce=self.visitor.pending)))
  778. plaintext = "Please visit {} to confirm your application at {}\n".format(
  779. confirm_url,
  780. request.build_absolute_uri(self.visitor.get_absolute_url()))
  781. key = Key.objects.get_or_download(self.visitor.fpr)
  782. if not key.key_is_fresh(): key.update_key()
  783. encrypted = key.encrypt(plaintext.encode("utf8"))
  784. bemail.send_nonce("notification_mails/newperson.txt", self.visitor, encrypted_nonce=encrypted)
  785. return redirect(self.visitor.get_absolute_url())
  786. class NewnmConfirm(VisitorMixin, View):
  787. """
  788. Confirm a pending Person object, given its nonce
  789. """
  790. def get(self, request, nonce, *args, **kw):
  791. if self.visitor is None: raise PermissionDenied
  792. if self.visitor.pending != nonce: raise PermissionDenied
  793. self.visitor.pending = ""
  794. self.visitor.expires = now() + datetime.timedelta(days=30)
  795. self.visitor.save(audit_author=self.visitor, audit_notes="confirmed pending subscription")
  796. return redirect(self.visitor.get_absolute_url())