models.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406
  1. # coding: utf-8
  2. """
  3. Core models of the New Member site
  4. """
  5. from __future__ import print_function
  6. from __future__ import absolute_import
  7. from __future__ import division
  8. from __future__ import unicode_literals
  9. from django.utils.translation import ugettext_lazy as _
  10. from django.utils.timezone import utc, now
  11. from django.db import models
  12. from django.conf import settings
  13. from django.utils.timezone import now
  14. from django.core.urlresolvers import reverse
  15. from django.contrib.auth.models import BaseUserManager, PermissionsMixin
  16. from django.forms.models import model_to_dict
  17. from . import const
  18. from .fields import *
  19. from .utils import cached_property
  20. from backend.notifications import maybe_notify_applicant_on_progress
  21. import datetime
  22. import urllib
  23. import os.path
  24. import re
  25. import json
  26. from django.db.models.signals import post_save
  27. PROCESS_MAILBOX_DIR = getattr(settings, "PROCESS_MAILBOX_DIR_OLD", "/srv/nm.debian.org/mbox/applicants/")
  28. DM_IMPORT_DATE = getattr(settings, "DM_IMPORT_DATE", None)
  29. class Permissions(set):
  30. """
  31. Set of strings, each string represent a permission
  32. """
  33. pass
  34. class VisitorPermissions(Permissions):
  35. """
  36. Permissions of a visitor regardless of context
  37. """
  38. def __init__(self, visitor):
  39. self.visitor = visitor
  40. class PersonVisitorPermissions(VisitorPermissions):
  41. """
  42. Store NM-specific permissions
  43. """
  44. fddam_states = frozenset((const.PROGRESS_AM_OK, const.PROGRESS_FD_HOLD,
  45. const.PROGRESS_FD_OK, const.PROGRESS_DAM_HOLD, const.PROGRESS_DAM_OK))
  46. pre_dd_statuses = frozenset((const.STATUS_DC, const.STATUS_DC_GA,
  47. const.STATUS_DM, const.STATUS_DM_GA,
  48. const.STATUS_EMERITUS_DD, const.STATUS_EMERITUS_DM,
  49. const.STATUS_REMOVED_DD, const.STATUS_REMOVED_DM))
  50. dm_or_dd = frozenset((const.STATUS_DM, const.STATUS_DM_GA, const.STATUS_DD_U, const.STATUS_DD_NU))
  51. dd = frozenset((const.STATUS_DD_U, const.STATUS_DD_NU))
  52. def __init__(self, person, visitor):
  53. super(PersonVisitorPermissions, self).__init__(visitor)
  54. # Person being visited
  55. self.person = person.person
  56. # Processes of self.person
  57. #self.processes = list(self.person.processes.all())
  58. # If the person is already in LDAP, then nobody can edit their LDAP
  59. # info, since this database then becomes a read-only mirror of LDAP
  60. self.person_has_ldap_record = self.person.status not in (const.STATUS_DC, const.STATUS_DM)
  61. # Possible new statuses that the person can have
  62. self.person_possible_new_statuses = self.person.possible_new_statuses
  63. # True if there are active processes currently frozen for review
  64. self.person_has_frozen_processes = False
  65. old_frozen_progesses = frozenset((
  66. const.PROGRESS_AM_OK,
  67. const.PROGRESS_FD_HOLD,
  68. const.PROGRESS_FD_OK,
  69. const.PROGRESS_DAM_HOLD,
  70. const.PROGRESS_DAM_OK,
  71. const.PROGRESS_DONE,
  72. const.PROGRESS_CANCELLED,
  73. ))
  74. import process.models as pmodels
  75. if pmodels.Process.objects.filter(person=self.person, frozen_by__isnull=False).exists():
  76. self.person_has_frozen_processes = True
  77. elif Process.objects.filter(person=self.person, is_active=True, progress__in=old_frozen_progesses).exists():
  78. self.person_has_frozen_processes = True
  79. if self.visitor is None:
  80. pass
  81. elif self.visitor.is_admin:
  82. self._compute_admin_perms()
  83. elif self.visitor == self.person:
  84. self._compute_own_perms()
  85. elif self.visitor.is_active_am:
  86. self._compute_active_am_perms()
  87. elif self.visitor.is_dd:
  88. self._compute_dd_perms()
  89. def _compute_admin_perms(self):
  90. self.update(("edit_email", "edit_bio", "update_keycheck", "view_person_audit_log"))
  91. if self.person_possible_new_statuses: self.add("request_new_status")
  92. if not self.person_has_ldap_record: self.add("edit_ldap")
  93. self.add("fd_comments")
  94. def _compute_own_perms(self):
  95. self.update(("edit_email", "update_keycheck"))
  96. if not self.person_has_frozen_processes:
  97. if not self.person_has_ldap_record and not self.person.pending:
  98. self.add("edit_ldap")
  99. self.add("edit_bio")
  100. if self.person.pending: return
  101. self.add("view_person_audit_log")
  102. if self.person_possible_new_statuses: self.add("request_new_status")
  103. def _compute_active_am_perms(self):
  104. self.update(("update_keycheck", "view_person_audit_log"))
  105. if not self.person_has_frozen_processes:
  106. self.add("edit_bio")
  107. if not self.person_has_ldap_record: self.add("edit_ldap")
  108. def _compute_dd_perms(self):
  109. self.update(("update_keycheck", "view_person_audit_log"))
  110. # TODO: advocate view audit log
  111. class ProcessVisitorPermissions(PersonVisitorPermissions):
  112. """
  113. Permissions for visiting old-style Processes
  114. """
  115. def __init__(self, process, visitor):
  116. super(ProcessVisitorPermissions, self).__init__(process.person, visitor)
  117. self.process = process
  118. if self.visitor is None:
  119. pass
  120. elif self.visitor.is_admin:
  121. self.add("view_mbox")
  122. elif self.visitor == self.person:
  123. self.add("view_mbox")
  124. elif self.visitor.is_active_am:
  125. self.add("view_mbox")
  126. elif self.process.advocates.filter(pk=self.visitor.pk).exists():
  127. self.add("view_mbox")
  128. class PersonManager(BaseUserManager):
  129. def create_user(self, email, **other_fields):
  130. if not email:
  131. raise ValueError('Users must have an email address')
  132. audit_author = other_fields.pop("audit_author", None)
  133. audit_notes = other_fields.pop("audit_notes", None)
  134. audit_skip = other_fields.pop("audit_skip", False)
  135. user = self.model(
  136. email=self.normalize_email(email),
  137. **other_fields
  138. )
  139. user.save(using=self._db, audit_author=audit_author, audit_notes=audit_notes, audit_skip=audit_skip)
  140. return user
  141. def create_superuser(self, email, **other_fields):
  142. other_fields["is_superuser"] = True
  143. return self.create_user(email, **other_fields)
  144. def get_or_none(self, *args, **kw):
  145. """
  146. Same as get(), but returns None instead of raising DoesNotExist if the
  147. object cannot be found
  148. """
  149. try:
  150. return self.get(*args, **kw)
  151. except self.model.DoesNotExist:
  152. return None
  153. def get_from_other_db(self, other_db_name, uid=None, email=None, fpr=None, username=None, format_person=lambda x:unicode(x)):
  154. """
  155. Get one Person entry matching the informations that another database
  156. has about a person.
  157. One or more of uid, email, fpr and username must be provided, and the
  158. function will ensure consistency in the results. That is, only one
  159. person will be returned, and it will raise an exception if the data
  160. provided match different Person entries in our database.
  161. other_db_name is the name of the database where the parameters come
  162. from, to use in generating exception messages.
  163. It returns None if nothing is matched.
  164. """
  165. candidates = []
  166. if uid is not None:
  167. p = self.get_or_none(uid=uid)
  168. if p is not None:
  169. candidates.append((p, "uid", uid))
  170. if email is not None:
  171. p = self.get_or_none(email=email)
  172. if p is not None:
  173. candidates.append((p, "email", email))
  174. if fpr is not None:
  175. p = self.get_or_none(fprs__fpr=fpr)
  176. if p is not None:
  177. candidates.append((p, "fingerprint", fpr))
  178. if username is not None:
  179. p = self.get_or_none(username=username)
  180. if p is not None:
  181. candidates.append((p, "SSO username", username))
  182. # No candidates, nothing was found
  183. if not candidates:
  184. return None
  185. candidate = candidates[0]
  186. # Check for conflicts in the database
  187. for person, match_type, match_value in candidates[1:]:
  188. if candidate[0].pk != person.pk:
  189. raise self.model.MultipleObjectsReturned(
  190. "{} has {} {}, which corresponds to two different users in our db: {} (by {} {}) and {} (by {} {})".format(
  191. other_db_name, match_type, match_value,
  192. format_person(candidate[0]), candidate[1], candidate[2],
  193. format_person(person), match_type, match_value))
  194. return candidate[0]
  195. class Person(PermissionsMixin, models.Model):
  196. """
  197. A person (DM, DD, AM, applicant, FD member, DAM, anything)
  198. """
  199. class Meta:
  200. db_table = "person"
  201. objects = PersonManager()
  202. # Standard Django user fields
  203. username = models.CharField(max_length=255, unique=True, help_text=_("Debian SSO username"))
  204. last_login = models.DateTimeField(_('last login'), default=now)
  205. date_joined = models.DateTimeField(_('date joined'), default=now)
  206. is_staff = models.BooleanField(default=False)
  207. #is_active = True
  208. # enrico> For people like Wookey, do you prefer we use only cn or only sn?
  209. # "sn" is used currently, and "cn" has a dash, but rather than
  210. # cargo-culting that in the new NM double check it with you
  211. # @sgran> cn would be more usual
  212. # @sgran> cn is the "whole name" and you can split it up into givenName + sn if you like
  213. # phil> Except that in Debian LDAP it isn't.
  214. # enrico> sgran: ok. should I use 'cn' for potential new cases then?
  215. # @sgran> phil: indeed
  216. # @sgran> but if we keep doing it the other way, we'll never be in a position to change
  217. # @sgran> enrico: please
  218. # enrico> sgran: ack
  219. # Most user fields mirror Debian LDAP fields
  220. # First/Given name, or only name in case of only one name
  221. cn = models.CharField("first name", max_length=250, null=False)
  222. mn = models.CharField("middle name", max_length=250, null=False, blank=True, default="")
  223. sn = models.CharField("last name", max_length=250, null=False, blank=True, default="")
  224. email = models.EmailField("email address", null=False, unique=True)
  225. email_ldap = models.EmailField("LDAP forwarding email address", null=False, blank=True)
  226. bio = models.TextField("short biography", blank=True, null=False, default="",
  227. help_text="Please enter here a short biographical information")
  228. # This is null for people who still have not picked one
  229. uid = CharNullField("Debian account name", max_length=32, null=True, unique=True, blank=True)
  230. # Membership status
  231. status = models.CharField("current status in the project", max_length=20, null=False,
  232. choices=[(x.tag, x.ldesc) for x in const.ALL_STATUS])
  233. status_changed = models.DateTimeField("when the status last changed", null=False, default=now)
  234. fd_comment = models.TextField("Front Desk comments", null=False, blank=True, default="")
  235. # null=True because we currently do not have the info for old entries
  236. created = models.DateTimeField("Person record created", null=True, default=now)
  237. expires = models.DateField("Expiration date for the account", null=True, blank=True, default=None,
  238. help_text="This person will be deleted after this date if the status is still {} and"
  239. " no Process has started".format(const.STATUS_DC))
  240. pending = models.CharField("Nonce used to confirm this pending record", max_length=255, unique=False, blank=True)
  241. def get_full_name(self):
  242. return self.fullname
  243. def get_short_name(self):
  244. return self.cn
  245. def get_username(self):
  246. return self.username
  247. def is_anonymous(self):
  248. return False
  249. def is_authenticated(self):
  250. return True
  251. def is_active(self):
  252. return True
  253. def set_password(self, raw_password):
  254. pass
  255. def check_password(self, raw_password):
  256. return False
  257. def set_unusable_password(self):
  258. pass
  259. def has_usable_password(self):
  260. return False
  261. @property
  262. def fingerprint(self):
  263. """
  264. Return the Fingerprint associated to this person, or None if there is
  265. none
  266. """
  267. # If there is more than one active fingerprint, return a random one.
  268. # This should not happen, and a nightly maintenance task will warn if
  269. # it happens.
  270. for f in self.fprs.filter(is_active=True):
  271. return f
  272. return None
  273. @property
  274. def fpr(self):
  275. """
  276. Return the current fingerprint for this Person
  277. """
  278. f = self.fingerprint
  279. if f is not None: return f.fpr
  280. return None
  281. USERNAME_FIELD = 'username'
  282. REQUIRED_FIELDS = ["cn", "email", "status"]
  283. @property
  284. def person(self):
  285. """
  286. Allow to call foo.person to get a Person record, regardless if foo is a Person or an AM
  287. """
  288. return self
  289. @cached_property
  290. def perms(self):
  291. """
  292. Get permission tags for this user
  293. """
  294. res = set()
  295. is_dd = self.status in (const.STATUS_DD_U, const.STATUS_DD_NU)
  296. if is_dd:
  297. res.add("dd")
  298. am = self.am_or_none
  299. if am:
  300. res.add("am")
  301. if am.is_admin: res.add("admin")
  302. else:
  303. res.add("am_candidate")
  304. return frozenset(res)
  305. @property
  306. def is_dd(self):
  307. return "dd" in self.perms
  308. @property
  309. def is_am(self):
  310. return "am" in self.perms
  311. @property
  312. def is_active_am(self):
  313. try:
  314. return self.am.is_am
  315. except AM.DoesNotExist:
  316. return False
  317. @property
  318. def is_admin(self):
  319. return "admin" in self.perms
  320. def can_become_am(self):
  321. """
  322. Check if the person can become an AM
  323. """
  324. return "am_candidate" in self.perms
  325. @property
  326. def am_or_none(self):
  327. try:
  328. return self.am
  329. except AM.DoesNotExist:
  330. return None
  331. @property
  332. def changed_before_data_import(self):
  333. return DM_IMPORT_DATE is not None and self.status in (const.STATUS_DM, const.STATUS_DM_GA) and self.status_changed <= DM_IMPORT_DATE
  334. def permissions_of(self, visitor):
  335. """
  336. Compute which PersonVisitorPermissions the given person has over this person
  337. """
  338. return PersonVisitorPermissions(self, visitor)
  339. @property
  340. def fullname(self):
  341. if not self.mn:
  342. if not self.sn:
  343. return self.cn
  344. else:
  345. return "{} {}".format(self.cn, self.sn)
  346. else:
  347. if not self.sn:
  348. return "{} {}".format(self.cn, self.mn)
  349. else:
  350. return "{} {} {}".format(self.cn, self.mn, self.sn)
  351. @property
  352. def preferred_email(self):
  353. """
  354. Return uid@debian.org if the person is a DD, else return the email
  355. field.
  356. """
  357. if self.status in (const.STATUS_DD_U, const.STATUS_DD_NU):
  358. return "{}@debian.org".format(self.uid)
  359. else:
  360. return self.email
  361. def __unicode__(self):
  362. return u"{} <{}>".format(self.fullname, self.email)
  363. def __repr__(self):
  364. return "{} <{}> [uid:{}, status:{}]".format(
  365. self.fullname.encode("unicode_escape"), self.email, self.uid, self.status)
  366. @models.permalink
  367. def get_absolute_url(self):
  368. return ("person", (), dict(key=self.lookup_key))
  369. def get_admin_url(self):
  370. return reverse("admin:backend_person_change", args=[self.pk])
  371. @property
  372. def a_link(self):
  373. from django.utils.safestring import mark_safe
  374. from django.utils.html import conditional_escape
  375. return mark_safe("<a href='{}'>{}</a>".format(
  376. conditional_escape(self.get_absolute_url()),
  377. conditional_escape(self.lookup_key)))
  378. def get_ddpo_url(self):
  379. return u"http://qa.debian.org/developer.php?{}".format(urllib.urlencode(dict(login=self.preferred_email)))
  380. def get_portfolio_url(self):
  381. parms = dict(
  382. email=self.preferred_email,
  383. name=self.fullname.encode("utf-8"),
  384. gpgfp="",
  385. username="",
  386. nonddemail=self.email,
  387. aliothusername="",
  388. wikihomepage="",
  389. forumsid=""
  390. )
  391. if self.fpr:
  392. parms["gpgfp"] = self.fpr
  393. if self.uid:
  394. parms["username"] = self.uid
  395. return u"http://portfolio.debian.net/result?" + urllib.urlencode(parms)
  396. def get_contributors_url(self):
  397. if self.is_dd:
  398. return "https://contributors.debian.org/contributor/{}@debian".format(self.uid)
  399. elif self.username.endswith("@users.alioth.debian.org"):
  400. return "https://contributors.debian.org/contributor/{}@alioth".format(self.username[:-24])
  401. else:
  402. return None
  403. _new_status_table = {
  404. const.STATUS_DC: [const.STATUS_DC_GA, const.STATUS_DM, const.STATUS_DD_U, const.STATUS_DD_NU],
  405. const.STATUS_DC_GA: [const.STATUS_DM_GA, const.STATUS_DD_U, const.STATUS_DD_NU],
  406. const.STATUS_DM: [const.STATUS_DM_GA, const.STATUS_DD_NU, const.STATUS_DD_U],
  407. const.STATUS_DM_GA: [const.STATUS_DD_NU, const.STATUS_DD_U],
  408. const.STATUS_DD_NU: [const.STATUS_DD_U],
  409. const.STATUS_EMERITUS_DD: [const.STATUS_DD_U, const.STATUS_DD_NU],
  410. const.STATUS_REMOVED_DD: [const.STATUS_DD_U, const.STATUS_DD_NU],
  411. }
  412. @property
  413. def possible_new_statuses(self):
  414. """
  415. Return a list of possible new statuses that can be requested for the
  416. person
  417. """
  418. if self.pending: return []
  419. statuses = list(self._new_status_table.get(self.status, []))
  420. # Remove statuses from active processes
  421. applying_for = []
  422. if statuses:
  423. for proc in Process.objects.filter(person=self, is_active=True):
  424. applying_for.append(proc.applying_for)
  425. if statuses:
  426. import process.models as pmodels
  427. for proc in pmodels.Process.objects.filter(person=self, closed__isnull=True):
  428. applying_for.append(proc.applying_for)
  429. if const.STATUS_DD_U in applying_for: applying_for.append(const.STATUS_DD_NU)
  430. if const.STATUS_DD_NU in applying_for: applying_for.append(const.STATUS_DD_U)
  431. for status in applying_for:
  432. try:
  433. statuses.remove(status)
  434. except ValueError:
  435. pass
  436. return statuses
  437. @property
  438. def active_processes(self):
  439. """
  440. Return a list of all the active Processes for this person, if any; else
  441. the empty list.
  442. """
  443. return list(Process.objects.filter(person=self, is_active=True).order_by("id"))
  444. def make_pending(self, days_valid=30):
  445. """
  446. Make this person a pending person.
  447. It does not automatically save the Person.
  448. """
  449. from django.utils.crypto import get_random_string
  450. self.pending = get_random_string(length=12,
  451. allowed_chars='abcdefghijklmnopqrstuvwxyz'
  452. 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
  453. self.expires = now().date() + datetime.timedelta(days=days_valid)
  454. def save(self, *args, **kw):
  455. """
  456. Save, and add an entry to the Person audit log.
  457. Extra arguments that can be passed:
  458. audit_author: Person instance of the person doing the change
  459. audit_notes: free form text annotations for this change
  460. audit_skip: skip audit logging, used only for tests
  461. """
  462. # Extract our own arguments, so that they are not passed to django
  463. author = kw.pop("audit_author", None)
  464. notes = kw.pop("audit_notes", "")
  465. audit_skip = kw.pop("audit_skip", False)
  466. if audit_skip:
  467. changes = None
  468. else:
  469. # Get the previous version of the Person object, so that PersonAuditLog
  470. # can compute differences
  471. if self.pk:
  472. old_person = Person.objects.get(pk=self.pk)
  473. else:
  474. old_person = None
  475. changes = PersonAuditLog.diff(old_person, self)
  476. if changes and not author:
  477. raise RuntimeError("Cannot save a Person instance without providing Author information")
  478. # Perform the save; if we are creating a new person, this will also
  479. # fill in the id/pk field, so that PersonAuditLog can link to us
  480. super(Person, self).save(*args, **kw)
  481. # Finally, create the audit log entry
  482. if changes:
  483. PersonAuditLog.objects.create(person=self, author=author, notes=notes, changes=PersonAuditLog.serialize_changes(changes))
  484. @property
  485. def lookup_key(self):
  486. """
  487. Return a key that can be used to look up this person in the database
  488. using Person.lookup.
  489. Currently, this is the uid if available, else the email.
  490. """
  491. if self.uid:
  492. return self.uid
  493. elif self.email:
  494. return self.email
  495. else:
  496. return self.fpr
  497. @classmethod
  498. def lookup(cls, key):
  499. try:
  500. if "@" in key:
  501. return cls.objects.get(email=key)
  502. elif re.match(r"^[0-9A-Fa-f]{32,40}$", key):
  503. return cls.objects.get(fpr=key.upper())
  504. else:
  505. return cls.objects.get(uid=key)
  506. except cls.DoesNotExist:
  507. return None
  508. @classmethod
  509. def lookup_by_email(cls, addr):
  510. """
  511. Return the person corresponding to an email address, or None if no such
  512. person has been found.
  513. """
  514. try:
  515. return cls.objects.get(email=addr)
  516. except cls.DoesNotExist:
  517. pass
  518. if not addr.endswith("@debian.org"):
  519. return None
  520. try:
  521. return cls.objects.get(uid=addr[:-11])
  522. except cls.DoesNotExist:
  523. return None
  524. @classmethod
  525. def lookup_or_404(cls, key):
  526. from django.http import Http404
  527. res = cls.lookup(key)
  528. if res is not None:
  529. return res
  530. raise Http404
  531. class FingerprintManager(BaseUserManager):
  532. def create(self, **fields):
  533. audit_author = fields.pop("audit_author", None)
  534. audit_notes = fields.pop("audit_notes", None)
  535. audit_skip = fields.pop("audit_skip", False)
  536. res = self.model(**fields)
  537. res.save(using=self._db, audit_author=audit_author, audit_notes=audit_notes, audit_skip=audit_skip)
  538. return res
  539. class Fingerprint(models.Model):
  540. """
  541. A fingerprint for a person
  542. """
  543. class Meta:
  544. db_table = "fingerprints"
  545. objects = FingerprintManager()
  546. person = models.ForeignKey(Person, related_name="fprs")
  547. fpr = FingerprintField(verbose_name="OpenPGP key fingerprint", max_length=40, unique=True)
  548. is_active = models.BooleanField(default=False, help_text="whether this key is curently in use")
  549. def __unicode__(self):
  550. return self.fpr
  551. def get_key(self):
  552. from keyring.models import Key
  553. return Key.objects.get_or_download(self.fpr)
  554. def save(self, *args, **kw):
  555. """
  556. Save, and add an entry to the Person audit log.
  557. Extra arguments that can be passed:
  558. audit_author: Person instance of the person doing the change
  559. audit_notes: free form text annotations for this change
  560. audit_skip: skip audit logging, used only for tests
  561. """
  562. # Extract our own arguments, so that they are not passed to django
  563. author = kw.pop("audit_author", None)
  564. notes = kw.pop("audit_notes", "")
  565. audit_skip = kw.pop("audit_skip", False)
  566. if audit_skip:
  567. changes = None
  568. else:
  569. # Get the previous version of the Fingerprint object, so that
  570. # PersonAuditLog can compute differences
  571. if self.pk:
  572. existing_fingerprint = Fingerprint.objects.get(pk=self.pk)
  573. else:
  574. existing_fingerprint = None
  575. changes = PersonAuditLog.diff_fingerprint(existing_fingerprint, self)
  576. if changes and not author:
  577. raise RuntimeError("Cannot save a Fingerprint instance without providing Author information")
  578. # Perform the save; if we are creating a new person, this will also
  579. # fill in the id/pk field, so that PersonAuditLog can link to us
  580. super(Fingerprint, self).save(*args, **kw)
  581. # Finally, create the audit log entry
  582. if changes:
  583. if existing_fingerprint is not None and existing_fingerprint.person.pk != self.person.pk:
  584. PersonAuditLog.objects.create(person=existing_fingerprint.person, author=author, notes=notes, changes=PersonAuditLog.serialize_changes(changes))
  585. PersonAuditLog.objects.create(person=self.person, author=author, notes=notes, changes=PersonAuditLog.serialize_changes(changes))
  586. # If we are saving an active fingerprint, make all others inactive
  587. if self.is_active:
  588. for fpr in Fingerprint.objects.filter(person=self.person, is_active=True).exclude(pk=self.pk):
  589. fpr.is_active = False
  590. fpr.save(audit_notes=notes, audit_author=author, audit_skip=audit_skip)
  591. class PersonAuditLog(models.Model):
  592. person = models.ForeignKey(Person, related_name="audit_log")
  593. logdate = models.DateTimeField(null=False, auto_now_add=True)
  594. author = models.ForeignKey(Person, related_name="+", null=False)
  595. notes = models.TextField(null=False, default="")
  596. changes = models.TextField(null=False, default="{}")
  597. @classmethod
  598. def diff(cls, old_person, new_person):
  599. """
  600. Compute the changes between two different instances of a Person model
  601. """
  602. exclude = ["last_login", "date_joined"]
  603. changes = {}
  604. if old_person is None:
  605. for k, nv in model_to_dict(new_person, exclude=exclude).items():
  606. changes[k] = [None, nv]
  607. else:
  608. old = model_to_dict(old_person, exclude=exclude)
  609. new = model_to_dict(new_person, exclude=exclude)
  610. for k, nv in new.items():
  611. ov = old.get(k, None)
  612. # Also ignore changes like None -> ""
  613. if ov != nv and (ov or nv):
  614. changes[k] = [ov, nv]
  615. return changes
  616. @classmethod
  617. def diff_fingerprint(cls, existing_fpr, new_fpr):
  618. """
  619. Compute the changes between two different instances of a Fingerprint model
  620. """
  621. exclude = []
  622. changes = {}
  623. if existing_fpr is None:
  624. for k, nv in model_to_dict(new_fpr, exclude=exclude).items():
  625. changes["fpr:{}:{}".format(new_fpr.fpr, k)] = [None, nv]
  626. else:
  627. old = model_to_dict(existing_fpr, exclude=exclude)
  628. new = model_to_dict(new_fpr, exclude=exclude)
  629. for k, nv in new.items():
  630. ov = old.get(k, None)
  631. # Also ignore changes like None -> ""
  632. if ov != nv and (ov or nv):
  633. changes["fpr:{}:{}".format(existing_fpr.fpr, k)] = [ov, nv]
  634. return changes
  635. @classmethod
  636. def serialize_changes(cls, changes):
  637. class Serializer(json.JSONEncoder):
  638. def default(self, o):
  639. if isinstance(o, datetime.datetime):
  640. return o.strftime("%Y-%m-%d %H:%M:%S")
  641. elif isinstance(o, datetime.date):
  642. return o.strftime("%Y-%m-%d")
  643. else:
  644. return json.JSONEncoder.default(self, o)
  645. return json.dumps(changes, cls=Serializer)
  646. class AM(models.Model):
  647. """
  648. Extra info for people who are or have been AMs, FD members, or DAMs
  649. """
  650. class Meta:
  651. db_table = "am"
  652. person = models.OneToOneField(Person, related_name="am")
  653. slots = models.IntegerField(null=False, default=1)
  654. is_am = models.BooleanField("Active AM", null=False, default=True)
  655. is_fd = models.BooleanField("FD member", null=False, default=False)
  656. is_dam = models.BooleanField("DAM", null=False, default=False)
  657. # Automatically computed as true if any applicant was approved in the last
  658. # 6 months
  659. is_am_ctte = models.BooleanField("NM CTTE member", null=False, default=False)
  660. # null=True because we currently do not have the info for old entries
  661. created = models.DateTimeField("AM record created", null=True, default=now)
  662. fd_comment = models.TextField("Front Desk comments", null=False, blank=True, default="")
  663. def __unicode__(self):
  664. return u"%s %c%c%c" % (
  665. unicode(self.person),
  666. "a" if self.is_am else "-",
  667. "f" if self.is_fd else "-",
  668. "d" if self.is_dam else "-",
  669. )
  670. def __repr__(self):
  671. return "%s %c%c%c slots:%d" % (
  672. repr(self.person),
  673. "a" if self.is_am else "-",
  674. "f" if self.is_fd else "-",
  675. "d" if self.is_dam else "-",
  676. self.slots)
  677. @models.permalink
  678. def get_absolute_url(self):
  679. return ("person", (), dict(key=self.person.lookup_key))
  680. @property
  681. def is_admin(self):
  682. return self.is_fd or self.is_dam
  683. def applicant_stats(self):
  684. """
  685. Return 4 stats about the am (cur, max, hold, done).
  686. cur: number of active applicants
  687. max: number of slots
  688. hold: number of applicants on hold
  689. done: number of applicants successfully processed
  690. """
  691. cur = 0
  692. hold = 0
  693. done = 0
  694. for p in Process.objects.filter(manager=self):
  695. if p.progress == const.PROGRESS_DONE:
  696. done += 1
  697. elif p.progress == const.PROGRESS_AM_HOLD:
  698. hold += 1
  699. else:
  700. cur += 1
  701. return cur, self.slots, hold, done
  702. @classmethod
  703. def list_available(cls, free_only=False):
  704. """
  705. Get a list of active AMs with free slots, ordered by uid.
  706. Each AM is annotated with stats_active, stats_held and stats_free, with
  707. the number of NMs, held NMs and free slots.
  708. """
  709. from django.db import connection
  710. import process.models as pmodels
  711. ams = {}
  712. for am in AM.objects.all():
  713. am.proc_active = []
  714. am.proc_held = []
  715. ams[am] = am
  716. for p in Process.objects.filter(manager__isnull=False, is_active=True, progress__in=(const.PROGRESS_AM_RCVD, const.PROGRESS_AM, const.PROGRESS_AM_HOLD)).select_related("manager"):
  717. am = ams[p.manager]
  718. if p.progress == const.PROGRESS_AM_HOLD:
  719. am.proc_held.append(p)
  720. else:
  721. am.proc_active.append(p)
  722. for p in pmodels.AMAssignment.objects.filter(unassigned_by__isnull=True, process__frozen_by__isnull=True, process__approved_by__isnull=True, process__closed__isnull=True).select_related("am"):
  723. am = ams[p.am]
  724. if p.paused:
  725. am.proc_held.append(p)
  726. else:
  727. am.proc_active.append(p)
  728. res = []
  729. for am in ams.values():
  730. am.stats_active = len(am.proc_active)
  731. am.stats_held = len(am.proc_held)
  732. am.stats_free = am.slots - am.stats_active
  733. if free_only and am.stats_free <= 0:
  734. continue
  735. res.append(am)
  736. res.sort(key=lambda x: (-x.stats_free, x.stats_active))
  737. return res
  738. @property
  739. def lookup_key(self):
  740. """
  741. Return a key that can be used to look up this manager in the database
  742. using AM.lookup.
  743. Currently, this is the lookup key of the person.
  744. """
  745. return self.person.lookup_key
  746. @classmethod
  747. def lookup(cls, key):
  748. p = Person.lookup(key)
  749. if p is None: return None
  750. return p.am_or_none
  751. @classmethod
  752. def lookup_or_404(cls, key):
  753. from django.http import Http404
  754. res = cls.lookup(key)
  755. if res is not None:
  756. return res
  757. raise Http404
  758. class ProcessManager(models.Manager):
  759. def create_instant_process(self, person, new_status, steps):
  760. """
  761. Create a process for the given person to get new_status, with the given
  762. log entries. The 'process' field of the log entries in steps will be
  763. filled by this function.
  764. Return the newly created Process instance.
  765. """
  766. if not steps:
  767. raise ValueError("steps should not be empty")
  768. if not all(isinstance(s, Log) for s in steps):
  769. raise ValueError("all entries of steps must be instances of Log")
  770. # Create a process
  771. pr = Process(
  772. person=person,
  773. applying_as=person.status,
  774. applying_for=new_status,
  775. progress=steps[-1].progress,
  776. is_active=steps[-1].progress not in (const.PROGRESS_DONE, const.PROGRESS_CANCELLED),
  777. )
  778. pr.save()
  779. # Save all log entries
  780. for l in steps:
  781. l.process = pr
  782. l.save()
  783. return pr
  784. class Process(models.Model):
  785. """
  786. A process through which a person gets a new status
  787. There can be multiple 'Process'es per Person, but only one of them can be
  788. active at any one time. This is checked during maintenance.
  789. """
  790. class Meta:
  791. db_table = "process"
  792. # Custom manager
  793. objects = ProcessManager()
  794. person = models.ForeignKey(Person, related_name="processes")
  795. # 1.3-only: person = models.ForeignKey(Person, related_name="processes", on_delete=models.CASCADE)
  796. applying_as = models.CharField("original status", max_length=20, null=False,
  797. choices=[x[1:3] for x in const.ALL_STATUS])
  798. applying_for = models.CharField("target status", max_length=20, null=False,
  799. choices=[x[1:3] for x in const.ALL_STATUS])
  800. progress = models.CharField(max_length=20, null=False,
  801. choices=[x[1:3] for x in const.ALL_PROGRESS])
  802. # This is NULL until one gets a manager
  803. manager = models.ForeignKey(AM, related_name="processed", null=True, blank=True)
  804. # 1.3-only: manager = models.ForeignKey(AM, related_name="processed", null=True, on_delete=models.PROTECT)
  805. advocates = models.ManyToManyField(Person, related_name="advocated", blank=True,
  806. limit_choices_to={ "status__in": (const.STATUS_DD_U, const.STATUS_DD_NU) })
  807. # True if progress NOT IN (PROGRESS_DONE, PROGRESS_CANCELLED)
  808. is_active = models.BooleanField(null=False, default=False)
  809. closed = models.DateTimeField(null=True, blank=True, help_text=_("Date the process was closed, or NULL if still open"))
  810. archive_key = models.CharField("mailbox archive key", max_length=128, null=False, unique=True)
  811. def save(self, *args, **kw):
  812. if not self.archive_key:
  813. ts = now().strftime("%Y%m%d%H%M%S")
  814. if self.person.uid:
  815. self.archive_key = "-".join((ts, self.applying_for, self.person.uid))
  816. else:
  817. self.archive_key = "-".join((ts, self.applying_for, self.person.email))
  818. super(Process, self).save(*args, **kw)
  819. def __unicode__(self):
  820. return u"{} to become {} ({})".format(
  821. unicode(self.person),
  822. const.ALL_STATUS_DESCS.get(self.applying_for, self.applying_for),
  823. const.ALL_PROGRESS_DESCS.get(self.progress, self.progress),
  824. )
  825. def __repr__(self):
  826. return "{} {}->{}".format(
  827. self.person.lookup_key,
  828. self.person.status,
  829. self.applying_for)
  830. @models.permalink
  831. def get_absolute_url(self):
  832. return ("public_process", (), dict(key=self.lookup_key))
  833. def get_admin_url(self):
  834. return reverse("admin:backend_process_change", args=[self.pk])
  835. @property
  836. def a_link(self):
  837. from django.utils.safestring import mark_safe
  838. from django.utils.html import conditional_escape
  839. return mark_safe("<a href='{}'>→ {}</a>".format(
  840. conditional_escape(self.get_absolute_url()),
  841. conditional_escape(const.ALL_STATUS_DESCS[self.applying_for])))
  842. @property
  843. def lookup_key(self):
  844. """
  845. Return a key that can be used to look up this process in the database
  846. using Process.lookup.
  847. Currently, this is the email if the process is active, else the id.
  848. """
  849. # If the process is active, and we only have one process, use the
  850. # person's lookup key. In all other cases, use the process ID
  851. if self.is_active:
  852. if self.person.processes.filter(is_active=True).count() == 1:
  853. return self.person.lookup_key
  854. else:
  855. return str(self.id)
  856. else:
  857. return str(self.id)
  858. @classmethod
  859. def lookup(cls, key):
  860. # Key can either be a Process ID or a person's lookup key
  861. if key.isdigit():
  862. try:
  863. return cls.objects.get(id=int(key))
  864. except cls.DoesNotExist:
  865. return None
  866. else:
  867. # If a person's lookup key is used, and there is only one active
  868. # process, return that one. Else, return the most recent process.
  869. p = Person.lookup(key)
  870. if p is None:
  871. return None
  872. # If we reach here, either we have one process, or a new process
  873. # has been added # changed since the URL was generated. We have an
  874. # ambiguous situation, which we handle blissfully arbitrarily
  875. res = p.active_processes
  876. if res: return res[0]
  877. try:
  878. from django.db.models import Max
  879. return p.processes.annotate(last_change=Max("log__logdate")).order_by("-last_change")[0]
  880. except IndexError:
  881. return None
  882. @classmethod
  883. def lookup_or_404(cls, key):
  884. from django.http import Http404
  885. res = cls.lookup(key)
  886. if res is not None:
  887. return res
  888. raise Http404
  889. @property
  890. def mailbox_file(self):
  891. """
  892. The pathname of the archival mailbox, or None if it does not exist
  893. """
  894. fname = os.path.join(PROCESS_MAILBOX_DIR, self.archive_key) + ".mbox"
  895. if os.path.exists(fname):
  896. return fname
  897. return None
  898. @property
  899. def mailbox_mtime(self):
  900. """
  901. The mtime of the archival mailbox, or None if it does not exist
  902. """
  903. fname = self.mailbox_file
  904. if fname is None: return None
  905. return datetime.datetime.utcfromtimestamp(os.path.getmtime(fname)).replace(tzinfo=utc)
  906. @property
  907. def archive_email(self):
  908. if self.person.uid:
  909. key = self.person.uid
  910. else:
  911. key = self.person.email.replace("@", "=")
  912. return "archive-{}@nm.debian.org".format(key)
  913. def permissions_of(self, visitor):
  914. """
  915. Compute which ProcessVisitorPermissions \a visitor has over this process
  916. """
  917. return ProcessVisitorPermissions(self, visitor)
  918. class DurationStats(object):
  919. AM_STATUSES = frozenset((const.PROGRESS_AM_HOLD, const.PROGRESS_AM))
  920. def __init__(self):
  921. self.first = None
  922. self.last = None
  923. self.last_progress = None
  924. self.total_am_time = 0
  925. self.total_amhold_time = 0
  926. self.last_am_time = 0
  927. self.last_amhold_time = 0
  928. self.last_am_history = []
  929. self.last_log_text = None
  930. def process_last_am_history(self, end=None):
  931. """
  932. Compute AM duration stats.
  933. end is the datetime of the end of the AM stats period. If None, the
  934. current datetime is used.
  935. """
  936. if not self.last_am_history: return
  937. if end is None:
  938. end = now()
  939. time_for_progress = dict()
  940. period_start = None
  941. for l in self.last_am_history:
  942. if period_start is None:
  943. period_start = l
  944. elif l.progress != period_start.progress:
  945. days = (l.logdate - period_start.logdate).days
  946. time_for_progress[period_start.progress] = \
  947. time_for_progress.get(period_start.progress, 0) + days
  948. period_start = l
  949. if period_start:
  950. days = (end - period_start.logdate).days
  951. time_for_progress[period_start.progress] = \
  952. time_for_progress.get(period_start.progress, 0) + days
  953. self.last_am_time = time_for_progress.get(const.PROGRESS_AM, 0)
  954. self.last_amhold_time = time_for_progress.get(const.PROGRESS_AM_HOLD, 0)
  955. self.total_am_time += self.last_am_time
  956. self.total_amhold_time += self.last_amhold_time
  957. self.last_am_history = []
  958. def process_log(self, l):
  959. """
  960. Process a log entry. Log entries must be processed in cronological
  961. order.
  962. """
  963. if self.first is None: self.first = l
  964. if l.progress in self.AM_STATUSES:
  965. if self.last_progress not in self.AM_STATUSES:
  966. self.last_am_time = 0
  967. self.last_amhold_time = 0
  968. self.last_am_history.append(l)
  969. elif self.last_progress in self.AM_STATUSES:
  970. self.process_last_am_history(end=l.logdate)
  971. self.last = l
  972. self.last_progress = l.progress
  973. def stats(self):
  974. """
  975. Compute a dict with statistics
  976. """
  977. # Process pending AM history items: happens when the last log has
  978. # AM_STATUSES status
  979. self.process_last_am_history()
  980. if self.last is not None and self.first is not None:
  981. total_duration = (self.last.logdate-self.first.logdate).days
  982. else:
  983. total_duration = None
  984. return dict(
  985. # Date the process started
  986. log_first=self.first,
  987. # Date of the last log entry
  988. log_last=self.last,
  989. # Total duration in days
  990. total_duration=total_duration,
  991. # Days spent in AM
  992. total_am_time=self.total_am_time,
  993. # Days spent in AM_HOLD
  994. total_amhold_time=self.total_amhold_time,
  995. # Days spent in AM with the last AM
  996. last_am_time=self.last_am_time,
  997. # Days spent in AM_HOLD with the last AM
  998. last_amhold_time=self.last_amhold_time,
  999. # Last nonempty log text
  1000. last_log_text=self.last_log_text,
  1001. )
  1002. def duration_stats(self):
  1003. stats_maker = self.DurationStats()
  1004. for l in self.log.order_by("logdate"):
  1005. stats_maker.process_log(l)
  1006. return stats_maker.stats()
  1007. def annotate_with_duration_stats(self):
  1008. s = self.duration_stats()
  1009. for k, v in s.iteritems():
  1010. setattr(self, k, v)
  1011. def finalize(self, logtext, tstamp=None, audit_author=None, audit_notes=None):
  1012. """
  1013. Bring the process to completion, by setting its progress to DONE,
  1014. adding a log entry and updating the person status.
  1015. """
  1016. if self.progress != const.PROGRESS_DAM_OK:
  1017. raise ValueError("cannot finalise progress {}: status is {} instead of {}".format(
  1018. unicode(self), self.progress, const.PROGRESS_DAM_OK))
  1019. if tstamp is None:
  1020. tstamp = now()
  1021. self.progress = const.PROGRESS_DONE
  1022. self.person.status = self.applying_for
  1023. self.person.status_changed = tstamp
  1024. l = Log(
  1025. changed_by=None,
  1026. process=self,
  1027. progress=self.progress,
  1028. logdate=tstamp,
  1029. logtext=logtext
  1030. )
  1031. l.save()
  1032. self.save()
  1033. self.person.save(audit_author=audit_author, audit_notes=audit_notes)
  1034. class Log(models.Model):
  1035. """
  1036. A log entry about anything that happened during a process
  1037. """
  1038. class Meta:
  1039. db_table = "log"
  1040. changed_by = models.ForeignKey(Person, related_name="log_written", null=True)
  1041. # 1.3-only: changed_by = models.ForeignKey(Person, related_name="log_written", on_delete=models.PROTECT, null=True)
  1042. process = models.ForeignKey(Process, related_name="log")
  1043. # 1.3-only: process = models.ForeignKey(Process, related_name="log", on_delete=models.CASCADE)
  1044. # Copied from Process when the log entry is created
  1045. progress = models.CharField(max_length=20, null=False,
  1046. choices=[(x.tag, x.ldesc) for x in const.ALL_PROGRESS])
  1047. is_public = models.BooleanField(default=False, null=False)
  1048. logdate = models.DateTimeField(null=False, default=now)
  1049. logtext = models.TextField(null=False, blank=True, default="")
  1050. def __unicode__(self):
  1051. return u"{}: {}".format(self.logdate, self.logtext)
  1052. @property
  1053. def previous(self):
  1054. """
  1055. Return the previous log entry for this process.
  1056. This fails once every many years when the IDs wrap around, in which
  1057. case it may say that there are no previous log entries. It is ok if you
  1058. use it to send a mail notification, just do not use this method to
  1059. control a nuclear power plant.
  1060. """
  1061. try:
  1062. return Log.objects.filter(id__lt=self.id, process=self.process).order_by("-id")[0]
  1063. except IndexError:
  1064. return None
  1065. @classmethod
  1066. def for_process(cls, proc, **kw):
  1067. kw.setdefault("process", proc)
  1068. kw.setdefault("progress", proc.progress)
  1069. return cls(**kw)
  1070. def post_save_log(sender, **kw):
  1071. log = kw.get('instance', None)
  1072. if sender is not Log or not log or kw.get('raw', False):
  1073. return
  1074. if 'created' not in kw:
  1075. # this is a django BUG
  1076. return
  1077. if kw.get('created'):
  1078. # checks for progress transition
  1079. previous_log = log.previous
  1080. if previous_log is None or previous_log.progress == log.progress:
  1081. return
  1082. ### evaluate the progress transition to notify applicant
  1083. ### remember we are during Process.save() method execution
  1084. maybe_notify_applicant_on_progress(log, previous_log)
  1085. post_save.connect(post_save_log, sender=Log, dispatch_uid="Log_post_save_signal")
  1086. MOCK_FD_COMMENTS = [
  1087. "Cannot get GPG signatures because of extremely sensitive teeth",
  1088. "Only has internet connection on days which are prime numbers",
  1089. "Is a werewolf: warn AM to ignore replies when moon is full",
  1090. "Is a vampire: warn AM not to invite him/her into their home",
  1091. "Is a daemon: if unresponsive, contact Enrico for details about summoning ritual",
  1092. ]
  1093. MOCK_LOGTEXTS = [
  1094. "ok", "hmm", "meh", "asdf", "moo", "...", u"üñįç♥ḋə"
  1095. ]
  1096. def export_db(full=False):
  1097. """
  1098. Export the whole databae into a json-serializable array.
  1099. If full is False, then the output is stripped of privacy-sensitive
  1100. information.
  1101. """
  1102. import random
  1103. fd = list(Person.objects.filter(am__is_fd=True))
  1104. # Use order_by so that dumps are easier to diff
  1105. for idx, p in enumerate(Person.objects.all().order_by("uid", "email")):
  1106. # Person details
  1107. ep = dict(
  1108. username=p.username,
  1109. key=p.lookup_key,
  1110. cn=p.cn,
  1111. mn=p.mn,
  1112. sn=p.sn,
  1113. email=p.email,
  1114. uid=p.uid,
  1115. fpr=p.fpr,
  1116. is_staff=p.is_staff,
  1117. is_superuser=p.is_superuser,
  1118. status=p.status,
  1119. status_changed=p.status_changed,
  1120. created=p.created,
  1121. fd_comment=None,
  1122. am=None,
  1123. processes=[],
  1124. )
  1125. if full:
  1126. ep["fd_comment"] = p.fd_comment
  1127. else:
  1128. if random.randint(1, 100) < 20:
  1129. ep["fd_comment"] = random.choice(MOCK_FD_COMMENTS)
  1130. # AM details
  1131. am = p.am_or_none
  1132. if am:
  1133. ep["am"] = dict(
  1134. slots=am.slots,
  1135. is_am=am.is_am,
  1136. is_fd=am.is_fd,
  1137. is_dam=am.is_dam,
  1138. is_am_ctte=am.is_am_ctte,
  1139. created=am.created)
  1140. # Process details
  1141. for pr in p.processes.all().order_by("applying_for"):
  1142. epr = dict(
  1143. applying_as=pr.applying_as,
  1144. applying_for=pr.applying_for,
  1145. progress=pr.progress,
  1146. is_active=pr.is_active,
  1147. archive_key=pr.archive_key,
  1148. manager=None,
  1149. advocates=[],
  1150. log=[],
  1151. )
  1152. ep["processes"].append(epr)
  1153. # Also get a list of actors who can be used for mock logging later
  1154. if pr.manager:
  1155. epr["manager"] = pr.manager.lookup_key
  1156. actors = [pr.manager.person] + fd
  1157. else:
  1158. actors = fd
  1159. for a in pr.advocates.all():
  1160. epr["advocates"].append(a.lookup_key)
  1161. # Log details
  1162. last_progress = None
  1163. for l in pr.log.all().order_by("logdate"):
  1164. if not full and last_progress == l.progress:
  1165. # Consolidate consecutive entries to match simplification
  1166. # done by public interface
  1167. continue
  1168. el = dict(
  1169. changed_by=None,
  1170. progress=l.progress,
  1171. logdate=l.logdate,
  1172. logtext=None)
  1173. if full:
  1174. if l.changed_by:
  1175. el["changed_by"] = l.changed_by.lookup_key
  1176. el["logtext"] = l.logtext
  1177. else:
  1178. if l.changed_by:
  1179. el["changed_by"] = random.choice(actors).lookup_key
  1180. el["logtext"] = random.choice(MOCK_LOGTEXTS)
  1181. epr["log"].append(el)
  1182. last_progress = l.progress
  1183. yield ep