models.py 80 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432
  1. # Copyright 2013-2016 The Distro Tracker Developers
  2. # See the COPYRIGHT file at the top-level directory of this distribution and
  3. # at http://deb.li/DTAuthors
  4. #
  5. # This file is part of Distro Tracker. It is subject to the license terms
  6. # in the LICENSE file found in the top-level directory of this
  7. # distribution and at http://deb.li/DTLicense. No part of Distro Tracker,
  8. # including this file, may be copied, modified, propagated, or distributed
  9. # except according to the terms contained in the LICENSE file.
  10. """Models for the :mod:`distro_tracker.core` app."""
  11. from __future__ import unicode_literals
  12. from email.utils import getaddresses
  13. from email.utils import parseaddr
  14. from email.iterators import typed_subpart_iterator
  15. from jsonfield import JSONField
  16. import os
  17. import hashlib
  18. import string
  19. import random
  20. import re
  21. from debian.debian_support import AptPkgVersion
  22. from debian import changelog as debian_changelog
  23. from django.core.exceptions import ValidationError
  24. from django.db import models
  25. from django.db.utils import IntegrityError
  26. from django.utils import six
  27. from django.utils import timezone
  28. from django.utils.encoding import python_2_unicode_compatible
  29. from django.utils.encoding import force_text
  30. from django.utils.html import escape
  31. from django.utils.functional import cached_property
  32. from django.utils.safestring import mark_safe
  33. from django.core.urlresolvers import reverse
  34. from django.conf import settings
  35. from django.core.files.base import ContentFile
  36. from django.template.defaultfilters import slugify
  37. from django_email_accounts.models import UserEmail
  38. from distro_tracker.core.utils import get_or_none
  39. from distro_tracker.core.utils import SpaceDelimitedTextField
  40. from distro_tracker.core.utils import verify_signature
  41. from distro_tracker.core.utils import distro_tracker_render_to_string
  42. from distro_tracker.core.utils.plugins import PluginRegistry
  43. from distro_tracker.core.utils.email_messages import decode_header
  44. from distro_tracker.core.utils.email_messages import get_decoded_message_payload
  45. from distro_tracker.core.utils.email_messages import message_from_bytes
  46. from distro_tracker.core.utils.packages import package_hashdir
  47. from distro_tracker.core.utils.linkify import linkify
  48. DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS = \
  49. settings.DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS
  50. @python_2_unicode_compatible
  51. class Keyword(models.Model):
  52. """
  53. Describes a keyword which can be used to tag package messages.
  54. """
  55. name = models.CharField(max_length=50, unique=True)
  56. default = models.BooleanField(default=False)
  57. description = models.CharField(max_length=256, blank=True)
  58. def __str__(self):
  59. return self.name
  60. @python_2_unicode_compatible
  61. class EmailSettings(models.Model):
  62. """
  63. Settings for an email
  64. """
  65. user_email = models.OneToOneField(UserEmail)
  66. default_keywords = models.ManyToManyField(Keyword)
  67. def __str__(self):
  68. return self.email
  69. @cached_property
  70. def email(self):
  71. return self.user_email.email
  72. @cached_property
  73. def user(self):
  74. return self.user_email.user
  75. def save(self, *args, **kwargs):
  76. """
  77. Overrides the default save method to add the set of default keywords to
  78. the user's own default keywords after creating an instance.
  79. """
  80. new_object = not self.id
  81. models.Model.save(self, *args, **kwargs)
  82. if new_object:
  83. self.default_keywords = Keyword.objects.filter(default=True)
  84. def is_subscribed_to(self, package):
  85. """
  86. Checks if the user is subscribed to the given package.
  87. :param package: The package (or package name)
  88. :type package: :class:`Package` or string
  89. """
  90. if not isinstance(package, PackageName):
  91. package = get_or_none(PackageName, name=package)
  92. if not package:
  93. return False
  94. return package in (
  95. subscription.package
  96. for subscription in self.subscription_set.all_active()
  97. )
  98. def unsubscribe_all(self):
  99. """
  100. Terminates all of the user's subscriptions.
  101. """
  102. self.subscription_set.all().delete()
  103. class PackageManagerQuerySet(models.query.QuerySet):
  104. """
  105. A custom :class:`PackageManagerQuerySet <django.db.models.query.QuerySet>`
  106. for the :class:`PackageManager` manager. It is needed in order to change
  107. the bulk delete behavior.
  108. """
  109. def delete(self):
  110. """
  111. In the bulk delete, the only cases when an item should be deleted is:
  112. - when the bulk delete is made directly from the PackageName class
  113. Else, the field corresponding to the package type you want to delete
  114. should be set to False.
  115. """
  116. if self.model.objects.type is None:
  117. # Means the bulk delete is done from the PackageName class
  118. super(PackageManagerQuerySet, self).delete()
  119. else:
  120. # Called from a proxy class: here, this is only a soft delete
  121. self.update(**{self.model.objects.type: False})
  122. class PackageManager(models.Manager):
  123. """
  124. A custom :class:`Manager <django.db.models.Manager>` for the
  125. :class:`PackageName` model.
  126. """
  127. def __init__(self, package_type=None, *args, **kwargs):
  128. super(PackageManager, self).__init__(*args, **kwargs)
  129. self.type = package_type
  130. def get_queryset(self):
  131. """
  132. Overrides the default query set of the manager to exclude any
  133. :class:`PackageName` objects with a type that does not match this
  134. manager instance's :attr:`type`.
  135. If the instance does not have a :attr:`type`, then all
  136. :class:`PackageName` instances are returned.
  137. """
  138. qs = PackageManagerQuerySet(self.model, using=self._db)
  139. if self.type is None:
  140. return qs
  141. return qs.filter(**{
  142. self.type: True,
  143. })
  144. def exists_with_name(self, package_name):
  145. """
  146. :param package_name: The name of the package
  147. :type package_name: string
  148. :returns True: If a package with the given name exists.
  149. """
  150. return self.filter(name=package_name).exists()
  151. def create(self, *args, **kwargs):
  152. """
  153. Overrides the default :meth:`create <django.db.models.Manager.create>`
  154. method to inject a :attr:`package_type <PackageName.package_type>` to
  155. the instance being created.
  156. The type is the type given in this manager instance's :attr:`type`
  157. attribute.
  158. """
  159. if self.type not in kwargs and self.type is not None:
  160. kwargs[self.type] = True
  161. return super(PackageManager, self).create(*args, **kwargs)
  162. def get_or_create(self, *args, **kwargs):
  163. """
  164. Overrides the default
  165. :meth:`get_or_create <django.db.models.Manager.get_or_create>`
  166. to set the correct package type.
  167. The type is the type given in this manager instance's :attr:`type`
  168. attribute.
  169. """
  170. defaults = kwargs.get('defaults', {})
  171. if self.type is not None:
  172. defaults.update({self.type: True})
  173. kwargs['defaults'] = defaults
  174. entry, created = PackageName.default_manager.get_or_create(*args,
  175. **kwargs)
  176. if self.type and getattr(entry, self.type) is False:
  177. created = True
  178. setattr(entry, self.type, True)
  179. entry.save()
  180. if isinstance(entry, self.model):
  181. return entry, created
  182. else:
  183. return self.get(pk=entry.pk), created
  184. def all_with_subscribers(self):
  185. """
  186. A method which filters the packages and returns a QuerySet
  187. containing only those which have at least one subscriber.
  188. :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>` of
  189. :py:class:`PackageName` instances.
  190. """
  191. qs = self.annotate(subscriber_count=models.Count('subscriptions'))
  192. return qs.filter(subscriber_count__gt=0)
  193. def get_by_name(self, package_name):
  194. """
  195. :returns: A package with the given name
  196. :rtype: :class:`PackageName`
  197. """
  198. return self.get(name=package_name)
  199. @python_2_unicode_compatible
  200. class PackageName(models.Model):
  201. """
  202. A model describing package names.
  203. Three different types of packages are supported:
  204. - Source packages
  205. - Binary packages
  206. - Pseudo packages
  207. PackageName associated to no source/binary/pseudo packages are
  208. referred to as "Subscription-only packages".
  209. """
  210. name = models.CharField(max_length=100, unique=True)
  211. source = models.BooleanField(default=False)
  212. binary = models.BooleanField(default=False)
  213. pseudo = models.BooleanField(default=False)
  214. subscriptions = models.ManyToManyField(EmailSettings,
  215. through='Subscription')
  216. objects = PackageManager()
  217. source_packages = PackageManager('source')
  218. binary_packages = PackageManager('binary')
  219. pseudo_packages = PackageManager('pseudo')
  220. default_manager = models.Manager()
  221. def __str__(self):
  222. return self.name
  223. def get_absolute_url(self):
  224. return reverse('dtracker-package-page', kwargs={
  225. 'package_name': self.name,
  226. })
  227. def get_package_type_display(self):
  228. if self.source:
  229. return 'Source package'
  230. elif self.binary:
  231. return 'Binary package'
  232. elif self.pseudo:
  233. return 'Pseudo package'
  234. else:
  235. return 'Subscription-only package'
  236. def get_action_item_for_type(self, action_item_type):
  237. """
  238. :param: The name of the :class:`ActionItemType` of the
  239. :class:`ActionItem` which is to be returned or an
  240. :class:`ActionItemType` instance.
  241. :type param: :class:`ActionItemType` or :class:`string`
  242. :returns: An action item with the given type name which is associated
  243. to this :class:`PackageName` instance. ``None`` if the package
  244. has no action items of that type.
  245. :rtype: :class:`ActionItem` or ``None``
  246. """
  247. if isinstance(action_item_type, ActionItemType):
  248. action_item_type = action_item_type.type_name
  249. return next((
  250. item
  251. for item in self.action_items.all()
  252. if item.item_type.type_name == action_item_type),
  253. None)
  254. def delete(self, *args, **kwargs):
  255. """
  256. Custom delete method so that PackageName proxy classes
  257. do not remove the underlying PackageName. Instead they update
  258. their corresponding "type" field to False so that they
  259. no longer find the package name.
  260. The delete method on PackageName keeps its default behaviour.
  261. """
  262. if self.__class__.objects.type:
  263. setattr(self, self.__class__.objects.type, False)
  264. self.save()
  265. else:
  266. super(self, PackageName).delete(*args, **kwargs)
  267. def save(self, *args, **kwargs):
  268. if not re.match('[0-9a-z][-+.0-9a-z]+$', self.name):
  269. raise ValidationError('Invalid package name: {}'.format(self.name))
  270. models.Model.save(self, *args, **kwargs)
  271. class PseudoPackageName(PackageName):
  272. """
  273. A convenience proxy model of the :class:`PackageName` model.
  274. It returns only those :class:`PackageName` instances whose
  275. :attr:`pseudo <PackageName.pseudo>` attribute is True.
  276. """
  277. class Meta:
  278. proxy = True
  279. objects = PackageManager('pseudo')
  280. class BinaryPackageName(PackageName):
  281. """
  282. A convenience proxy model of the :class:`PackageName` model.
  283. It returns only those :class:`PackageName` instances whose
  284. :attr:`binary <PackageName.binary>` attribute is True.
  285. """
  286. class Meta:
  287. proxy = True
  288. objects = PackageManager('binary')
  289. def get_absolute_url(self):
  290. # Take the URL of its source package
  291. main_source_package = self.main_source_package_name
  292. if main_source_package:
  293. return main_source_package.get_absolute_url()
  294. else:
  295. return None
  296. @property
  297. def main_source_package_name(self):
  298. """
  299. Returns the main source package name to which this binary package
  300. name is mapped.
  301. The "main source package" is defined as follows:
  302. - If the binary package is found in the default repository, the returned
  303. source package name is the one which has the highest version.
  304. - If the binary package is not found in the default repository, the
  305. returned source package name is the one of the source package with
  306. the highest version.
  307. :rtype: string
  308. This is used for redirecting users who try to access a Web page for
  309. by giving this binary's name.
  310. """
  311. default_repo_sources_qs = self.sourcepackage_set.filter(
  312. repository_entries__repository__default=True)
  313. if default_repo_sources_qs.exists():
  314. qs = default_repo_sources_qs
  315. else:
  316. qs = self.sourcepackage_set.all()
  317. if qs.exists():
  318. source_package = max(qs, key=lambda x: AptPkgVersion(x.version))
  319. return source_package.source_package_name
  320. else:
  321. return None
  322. class SourcePackageName(PackageName):
  323. """
  324. A convenience proxy model of the :class:`PackageName` model.
  325. It returns only those :class:`PackageName` instances whose
  326. :attr:`source <PackageName.source>` attribute is True.
  327. """
  328. class Meta:
  329. proxy = True
  330. objects = PackageManager('source')
  331. @cached_property
  332. def main_version(self):
  333. """
  334. Returns the main version of this :class:`SourcePackageName` instance.
  335. :rtype: string
  336. It is defined as either the highest version found in the default
  337. repository, or if the package is not found in the default repository at
  338. all, the highest available version.
  339. """
  340. default_repository_qs = self.source_package_versions.filter(
  341. repository_entries__repository__default=True)
  342. if default_repository_qs.exists():
  343. qs = default_repository_qs
  344. else:
  345. qs = self.source_package_versions.all()
  346. qs.select_related()
  347. try:
  348. return max(qs, key=lambda x: AptPkgVersion(x.version))
  349. except ValueError:
  350. return None
  351. @cached_property
  352. def main_entry(self):
  353. """
  354. Returns the :class:`SourcePackageRepositoryEntry` which represents the
  355. package's entry in either the default repository (if the package is
  356. found there) or in the first repository (as defined by the repository
  357. order) which has the highest available package version.
  358. """
  359. default_repository_qs = SourcePackageRepositoryEntry.objects.filter(
  360. repository__default=True,
  361. source_package__source_package_name=self
  362. )
  363. if default_repository_qs.exists():
  364. qs = default_repository_qs
  365. else:
  366. qs = SourcePackageRepositoryEntry.objects.filter(
  367. source_package__source_package_name=self)
  368. qs = qs.select_related()
  369. try:
  370. return max(
  371. qs,
  372. key=lambda x: AptPkgVersion(x.source_package.version)
  373. )
  374. except ValueError:
  375. return None
  376. @cached_property
  377. def repositories(self):
  378. """
  379. Returns all repositories which contain a source package with this name.
  380. :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>` of
  381. :py:class:`Repository` instances.
  382. """
  383. kwargs = {
  384. 'source_entries'
  385. '__source_package'
  386. '__source_package_name': self
  387. }
  388. return Repository.objects.filter(**kwargs).distinct()
  389. def short_description(self):
  390. """
  391. Returns the most recent short description for a source package. If there
  392. is a binary package whose name matches the source package, its
  393. description will be used. If not, the short description for the first
  394. binary package will be used.
  395. """
  396. if not self.main_version:
  397. return ''
  398. binary_packages = self.main_version.binarypackage_set.all()
  399. for pkg in binary_packages:
  400. if pkg.binary_package_name.name == self.name:
  401. return pkg.short_description
  402. if len(binary_packages) == 1:
  403. return binary_packages[0].short_description
  404. return ''
  405. def get_web_package(package_name):
  406. """
  407. Utility function mapping a package name to its most adequate Python
  408. representation (among :class:`SourcePackageName`,
  409. :class:`PseudoPackageName`, :class:`PackageName` and ``None``).
  410. The rules are simple: a source package is returned as SourcePackageName,
  411. a pseudo-package is returned as PseudoPackageName, a binary package
  412. is turned into the corresponding SourcePackageName (which might have a
  413. different name!).
  414. If the package name is known but is none of the above, it's only returned
  415. if it has associated :class:`News` since that proves that it used to be
  416. a former source package.
  417. If that is not the case, then ``None`` is returned.
  418. :rtype: :class:`PackageName` or ``None``
  419. :param package_name: The name for which a package should be found.
  420. :type package_name: string
  421. """
  422. if SourcePackageName.objects.exists_with_name(package_name):
  423. return SourcePackageName.objects.get(name=package_name)
  424. elif PseudoPackageName.objects.exists_with_name(package_name):
  425. return PseudoPackageName.objects.get(name=package_name)
  426. elif BinaryPackageName.objects.exists_with_name(package_name):
  427. binary_package = BinaryPackageName.objects.get(name=package_name)
  428. return binary_package.main_source_package_name
  429. elif PackageName.objects.exists_with_name(package_name):
  430. pkg = PackageName.objects.get(name=package_name)
  431. # This is not a current source or binary package, but if it has
  432. # associated news, then it's likely a former source package where we can
  433. # display something useful
  434. if pkg.news_set.count():
  435. return pkg
  436. return None
  437. class SubscriptionManager(models.Manager):
  438. """
  439. A custom :class:`Manager <django.db.models.Manager>` for the
  440. :class:`Subscription` class.
  441. """
  442. def create_for(self, package_name, email, active=True):
  443. """
  444. Creates a new subscription based on the given arguments.
  445. :param package_name: The name of the subscription package
  446. :type package_name: string
  447. :param email: The email address of the user subscribing to the package
  448. :type email: string
  449. :param active: Indicates whether the subscription should be activated
  450. as soon as it is created.
  451. :returns: The subscription for the given ``(email, package_name)`` pair.
  452. :rtype: :class:`Subscription`
  453. """
  454. package = get_or_none(PackageName, name=package_name)
  455. if not package:
  456. # If the package did not previously exist, create a
  457. # "subscriptions-only" package.
  458. package = PackageName.objects.create(
  459. name=package_name)
  460. user_email, _ = UserEmail.objects.get_or_create(email=email)
  461. email_settings, _ = EmailSettings.objects.get_or_create(
  462. user_email=user_email)
  463. subscription, _ = self.get_or_create(email_settings=email_settings,
  464. package=package)
  465. subscription.active = active
  466. subscription.save()
  467. return subscription
  468. def unsubscribe(self, package_name, email):
  469. """
  470. Unsubscribes the given email from the given package.
  471. :param email: The email of the user
  472. :param package_name: The name of the package the user should be
  473. unsubscribed from
  474. :returns True: If the user was successfully unsubscribed
  475. :returns False: If the user was not unsubscribed, e.g. the subscription
  476. did not even exist.
  477. """
  478. package = get_or_none(PackageName, name=package_name)
  479. user_email = get_or_none(UserEmail, email__iexact=email)
  480. email_settings = get_or_none(EmailSettings, user_email=user_email)
  481. if not package or not user_email or not email_settings:
  482. return False
  483. subscription = get_or_none(
  484. Subscription, email_settings=email_settings, package=package)
  485. if subscription:
  486. subscription.delete()
  487. return True
  488. def get_for_email(self, email):
  489. """
  490. Returns a list of active subscriptions for the given user.
  491. :param email: The email address of the user
  492. :type email: string
  493. :rtype: ``iterable`` of :class:`Subscription` instances
  494. .. note::
  495. Since this method is not guaranteed to return a
  496. :py:class:`QuerySet <django.db.models.query.QuerySet>` object,
  497. clients should not count on chaining additional filters to the
  498. result.
  499. """
  500. user_email = get_or_none(UserEmail, email__iexact=email)
  501. email_settings = get_or_none(EmailSettings, user_email=user_email)
  502. if not user_email or not email_settings:
  503. return []
  504. return email_settings.subscription_set.all_active()
  505. def all_active(self, keyword=None):
  506. """
  507. Returns all active subscriptions, optionally filtered on having the
  508. given keyword.
  509. :rtype: ``iterable`` of :class:`Subscription` instances
  510. .. note::
  511. Since this method is not guaranteed to return a
  512. :py:class:`QuerySet <django.db.models.query.QuerySet>` object,
  513. clients should not count on chaining additional filters to the
  514. result.
  515. """
  516. actives = self.filter(active=True)
  517. if keyword:
  518. keyword = get_or_none(Keyword, name=keyword)
  519. if not keyword:
  520. return self.none()
  521. actives = [
  522. subscription
  523. for subscription in actives
  524. if keyword in subscription.keywords.all()
  525. ]
  526. return actives
  527. @python_2_unicode_compatible
  528. class Subscription(models.Model):
  529. """
  530. A model describing a subscription of a single :class:`EmailSettings` to a
  531. single :class:`PackageName`.
  532. """
  533. email_settings = models.ForeignKey(EmailSettings)
  534. package = models.ForeignKey(PackageName)
  535. active = models.BooleanField(default=True)
  536. _keywords = models.ManyToManyField(Keyword)
  537. _use_user_default_keywords = models.BooleanField(default=True)
  538. objects = SubscriptionManager()
  539. class KeywordsAdapter(object):
  540. """
  541. An adapter for accessing a :class:`Subscription`'s keywords.
  542. When a :class:`Subscription` is initially created, it uses the default
  543. keywords of the user. Only after modifying the subscription-specific
  544. keywords, should it use a different set of keywords.
  545. This class allows the clients of the class:`Subscription` class to
  546. access the :attr:`keywords <Subscription.keywords>` field without
  547. having to think about whether the subscription is using the user's
  548. keywords or not, rather the whole process is handled automatically and
  549. seamlessly.
  550. """
  551. def __init__(self, subscription):
  552. #: Keep a reference to the original subscription object
  553. self._subscription = subscription
  554. def __getattr__(self, name):
  555. # Methods which modify the set should cause it to become unlinked
  556. # from the user.
  557. if name in ('add', 'remove', 'create', 'clear', 'bulk_create'):
  558. self._unlink_from_user()
  559. return getattr(self._get_manager(), name)
  560. def _get_manager(self):
  561. """
  562. Helper method which returns the appropriate manager depending on
  563. whether the subscription is still using the user's keywords or not.
  564. """
  565. if self._subscription._use_user_default_keywords:
  566. manager = self._subscription.email_settings.default_keywords
  567. else:
  568. manager = self._subscription._keywords
  569. return manager
  570. def _unlink_from_user(self):
  571. """
  572. Helper method which unlinks the subscription from the user's
  573. default keywords.
  574. """
  575. if self._subscription._use_user_default_keywords:
  576. # Do not use the user's keywords anymore
  577. self._subscription._use_user_default_keywords = False
  578. # Copy the user's keywords
  579. email_settings = self._subscription.email_settings
  580. for keyword in email_settings.default_keywords.all():
  581. self._subscription._keywords.add(keyword)
  582. self._subscription.save()
  583. def __init__(self, *args, **kwargs):
  584. super(Subscription, self).__init__(*args, **kwargs)
  585. self.keywords = Subscription.KeywordsAdapter(self)
  586. def __str__(self):
  587. return str(self.email_settings.user_email) + ' ' + str(self.package)
  588. @python_2_unicode_compatible
  589. class Architecture(models.Model):
  590. """
  591. A model describing a single architecture.
  592. """
  593. name = models.CharField(max_length=30, unique=True)
  594. def __str__(self):
  595. return self.name
  596. class RepositoryManager(models.Manager):
  597. """
  598. A custom :class:`Manager <django.db.models.Manager>` for the
  599. :class:`Repository` model.
  600. """
  601. def get_default(self):
  602. """
  603. Returns the default :class:`Repository` instance.
  604. If there is no default repository, returns an empty
  605. :py:class:`QuerySet <django.db.models.query.QuerySet>`
  606. :rtype: :py:class:`QuerySet <django.db.models.query.QuerySet>`
  607. """
  608. return self.filter(default=True)
  609. @python_2_unicode_compatible
  610. class Repository(models.Model):
  611. """
  612. A model describing Debian repositories.
  613. """
  614. name = models.CharField(max_length=50, unique=True)
  615. shorthand = models.CharField(max_length=10, unique=True)
  616. uri = models.CharField(max_length=200, verbose_name='URI')
  617. public_uri = models.URLField(
  618. max_length=200,
  619. blank=True,
  620. verbose_name='public URI'
  621. )
  622. suite = models.CharField(max_length=50)
  623. codename = models.CharField(max_length=50, blank=True)
  624. components = SpaceDelimitedTextField()
  625. architectures = models.ManyToManyField(Architecture, blank=True)
  626. default = models.BooleanField(default=False)
  627. optional = models.BooleanField(default=True)
  628. binary = models.BooleanField(default=True)
  629. source = models.BooleanField(default=True)
  630. source_packages = models.ManyToManyField(
  631. 'SourcePackage',
  632. through='SourcePackageRepositoryEntry'
  633. )
  634. position = models.IntegerField(default=0)
  635. objects = RepositoryManager()
  636. class Meta:
  637. verbose_name_plural = "repositories"
  638. ordering = (
  639. 'position',
  640. )
  641. def __str__(self):
  642. return self.name
  643. @property
  644. def sources_list_entry(self):
  645. """
  646. Returns the sources.list entries based on the repository's attributes.
  647. :rtype: string
  648. """
  649. entry_common = (
  650. '{uri} {suite} {components}'.format(
  651. uri=self.uri,
  652. suite=self.suite,
  653. components=' '.join(self.components)
  654. )
  655. )
  656. src_entry = 'deb-src ' + entry_common
  657. if not self.binary:
  658. return src_entry
  659. else:
  660. bin_entry = 'deb [arch={archs}] ' + entry_common
  661. archs = ','.join(map(str, self.architectures.all()))
  662. bin_entry = bin_entry.format(archs=archs)
  663. return '\n'.join((src_entry, bin_entry))
  664. @property
  665. def component_urls(self):
  666. """
  667. Returns a list of URLs which represent full URLs for each of the
  668. components of the repository.
  669. :rtype: list
  670. """
  671. base_url = self.uri.rstrip('/')
  672. return [
  673. base_url + '/' + self.suite + '/' + component
  674. for component in self.components
  675. ]
  676. def get_source_package_entry(self, package_name):
  677. """
  678. Returns the canonical :class:`SourcePackageRepositoryEntry` with the
  679. given name, if found in the repository.
  680. This means the instance with the highest
  681. :attr:`version <SourcePackage.version>` is returned.
  682. If there is no :class:`SourcePackageRepositoryEntry` for the given name
  683. in this repository, returns ``None``.
  684. :param package_name: The name of the package for which the entry should
  685. be returned
  686. :type package_name: string or :class:`SourcePackageName`
  687. :rtype: :class:`SourcePackageRepositoryEntry` or ``None``
  688. """
  689. if isinstance(package_name, SourcePackageName):
  690. package_name = package_name.name
  691. qs = self.source_entries.filter(
  692. source_package__source_package_name__name=package_name)
  693. qs = qs.select_related()
  694. try:
  695. return max(
  696. qs,
  697. key=lambda x: AptPkgVersion(x.source_package.version))
  698. except ValueError:
  699. return None
  700. def add_source_package(self, package, **kwargs):
  701. """
  702. The method adds a new class:`SourcePackage` to the repository.
  703. :param package: The source package to add to the repository
  704. :type package: :class:`SourcePackage`
  705. The parameters needed for the corresponding
  706. :class:`SourcePackageRepositoryEntry` should be in the keyword
  707. arguments.
  708. Returns the newly created :class:`SourcePackageRepositoryEntry` for the
  709. given :class:`SourcePackage`.
  710. :rtype: :class:`SourcePackageRepositoryEntry`
  711. """
  712. entry = SourcePackageRepositoryEntry.objects.create(
  713. repository=self,
  714. source_package=package,
  715. **kwargs
  716. )
  717. return entry
  718. def has_source_package_name(self, source_package_name):
  719. """
  720. Checks whether this :class:`Repository` contains a source package with
  721. the given name.
  722. :param source_package_name: The name of the source package
  723. :type source_package_name: string
  724. :returns True: If it contains at least one version of the source package
  725. with the given name.
  726. :returns False: If it does not contain any version of the source package
  727. with the given name.
  728. """
  729. qs = self.source_packages.filter(
  730. source_package_name__name=source_package_name)
  731. return qs.exists()
  732. def has_source_package(self, source_package):
  733. """
  734. Checks whether this :class:`Repository` contains the given
  735. :class:`SourcePackage`.
  736. :returns True: If it does contain the given :class:`SourcePackage`
  737. :returns False: If it does not contain the given :class:`SourcePackage`
  738. """
  739. return self.source_packages.filter(id=source_package.id).exists()
  740. def has_binary_package(self, binary_package):
  741. """
  742. Checks whether this :class:`Repository` contains the given
  743. :class:`BinaryPackage`.
  744. :returns True: If it does contain the given :class:`SourcePackage`
  745. :returns False: If it does not contain the given :class:`SourcePackage`
  746. """
  747. qs = self.binary_entries.filter(binary_package=binary_package)
  748. return qs.exists()
  749. def add_binary_package(self, package, **kwargs):
  750. """
  751. The method adds a new class:`BinaryPackage` to the repository.
  752. :param package: The binary package to add to the repository
  753. :type package: :class:`BinaryPackage`
  754. The parameters needed for the corresponding
  755. :class:`BinaryPackageRepositoryEntry` should be in the keyword
  756. arguments.
  757. Returns the newly created :class:`BinaryPackageRepositoryEntry` for the
  758. given :class:`BinaryPackage`.
  759. :rtype: :class:`BinaryPackageRepositoryEntry`
  760. """
  761. return BinaryPackageRepositoryEntry.objects.create(
  762. repository=self,
  763. binary_package=package,
  764. **kwargs
  765. )
  766. @staticmethod
  767. def release_file_url(base_url, suite):
  768. """
  769. Returns the URL of the Release file for a repository with the given
  770. base URL and suite name.
  771. :param base_url: The base URL of the repository
  772. :type base_url: string
  773. :param suite: The name of the repository suite
  774. :type suite: string
  775. :rtype: string
  776. """
  777. base_url = base_url.rstrip('/')
  778. return base_url + '/dists/{suite}/Release'.format(
  779. suite=suite)
  780. def clean(self):
  781. """
  782. A custom model :meth:`clean <django.db.models.Model.clean>` method
  783. which enforces that only one :class:`Repository` can be set as the
  784. default.
  785. """
  786. super(Repository, self).clean()
  787. if self.default:
  788. # If this instance is not trying to set default to True, it is safe
  789. qs = Repository.objects.filter(default=True).exclude(pk=self.pk)
  790. if qs.exists():
  791. raise ValidationError(
  792. "Only one repository can be set as the default")
  793. def is_development_repository(self):
  794. """Returns a boolean indicating whether the repository is used for
  795. development.
  796. A development repository is a repository where new
  797. versions of packages tend to be uploaded. The list of development
  798. repositories can be provided in the list
  799. DISTRO_TRACKER_DEVEL_REPOSITORIES (it should contain codenames and/or
  800. suite names). If that setting does not exist, then the default
  801. repository is assumed to be the only development repository.
  802. :rtype: bool
  803. """
  804. if hasattr(settings, 'DISTRO_TRACKER_DEVEL_REPOSITORIES'):
  805. for repo in settings.DISTRO_TRACKER_DEVEL_REPOSITORIES:
  806. if self.codename == repo or self.suite == repo:
  807. return True
  808. else:
  809. return self.default
  810. return False
  811. def get_flags(self):
  812. """
  813. Returns a dict of existing flags and values. If no existing flag it
  814. returns the default value.
  815. """
  816. d = {}
  817. for flag, defvalue in RepositoryFlag.FLAG_DEFAULT_VALUES.items():
  818. d[flag] = defvalue
  819. for flag in self.flags.all():
  820. d[flag.name] = flag.value
  821. return d
  822. class RepositoryFlag(models.Model):
  823. """
  824. Boolean options associated to repositories.
  825. """
  826. FLAG_NAMES = (
  827. ('hidden', 'Hidden repository'),
  828. )
  829. FLAG_DEFAULT_VALUES = {
  830. 'hidden': False,
  831. }
  832. repository = models.ForeignKey(Repository, related_name='flags')
  833. name = models.CharField(max_length=50, choices=FLAG_NAMES)
  834. value = models.BooleanField(default=False)
  835. class Meta:
  836. unique_together = ('repository', 'name')
  837. class RepositoryRelation(models.Model):
  838. """
  839. Relations between two repositories. The relations are to be interpreted
  840. like "<repository> is a <relation> of <target_repository>".
  841. """
  842. RELATION_NAMES = (
  843. ('derivative', 'Derivative repository (target=parent)'),
  844. ('overlay', 'Overlay of target repository'),
  845. )
  846. repository = models.ForeignKey(Repository, related_name='relations')
  847. name = models.CharField(max_length=50, choices=RELATION_NAMES)
  848. target_repository = models.ForeignKey(Repository,
  849. related_name='reverse_relations')
  850. class Meta:
  851. unique_together = ('repository', 'name')
  852. @python_2_unicode_compatible
  853. class ContributorName(models.Model):
  854. """
  855. Represents a contributor.
  856. A single contributor, as identified by his email, may have different
  857. written names in different contexts.
  858. """
  859. contributor_email = models.ForeignKey(UserEmail)
  860. name = models.CharField(max_length=60, blank=True)
  861. class Meta:
  862. unique_together = ('contributor_email', 'name')
  863. @cached_property
  864. def email(self):
  865. return self.contributor_email.email
  866. def __str__(self):
  867. return "{name} <{email}>".format(
  868. name=self.name,
  869. email=self.contributor_email)
  870. def to_dict(self):
  871. """
  872. Returns a dictionary representing a :class:`ContributorName`
  873. instance.
  874. :rtype: dict
  875. """
  876. return {
  877. 'name': self.name,
  878. 'email': self.contributor_email.email,
  879. }
  880. @python_2_unicode_compatible
  881. class SourcePackage(models.Model):
  882. """
  883. A model representing a single Debian source package.
  884. This means it holds any information regarding a (package_name, version)
  885. pair which is independent from the repository in which the package is
  886. found.
  887. """
  888. source_package_name = models.ForeignKey(
  889. SourcePackageName,
  890. related_name='source_package_versions')
  891. version = models.CharField(max_length=100)
  892. standards_version = models.CharField(max_length=550, blank=True)
  893. architectures = models.ManyToManyField(Architecture, blank=True)
  894. binary_packages = models.ManyToManyField(BinaryPackageName, blank=True)
  895. maintainer = models.ForeignKey(
  896. ContributorName,
  897. related_name='source_package',
  898. null=True)
  899. uploaders = models.ManyToManyField(
  900. ContributorName,
  901. related_name='source_packages_uploads_set'
  902. )
  903. dsc_file_name = models.CharField(max_length=255, blank=True)
  904. directory = models.CharField(max_length=255, blank=True)
  905. homepage = models.URLField(max_length=255, blank=True)
  906. vcs = JSONField()
  907. class Meta:
  908. unique_together = ('source_package_name', 'version')
  909. def __str__(self):
  910. return '{pkg}, version {ver}'.format(
  911. pkg=self.source_package_name, ver=self.version)
  912. @cached_property
  913. def name(self):
  914. """
  915. A convenience property returning the name of the package as a string.
  916. :rtype: string
  917. """
  918. return self.source_package_name.name
  919. @cached_property
  920. def main_entry(self):
  921. """
  922. Returns the
  923. :class:`SourcePackageRepositoryEntry
  924. <distro_tracker.core.models.SourcePackageRepositoryEntry>`
  925. found in the instance's :attr:`repository_entries` which should be
  926. considered the main entry for this version.
  927. If the version is found in the default repository, the entry for the
  928. default repository is returned.
  929. Otherwise, the entry for the repository with the highest
  930. :attr:`position <distro_tracker.core.models.Repository.position>`
  931. field is returned.
  932. If the source package version is not found in any repository,
  933. ``None`` is returned.
  934. """
  935. default_repository_entry_qs = self.repository_entries.filter(
  936. repository__default=True)
  937. try:
  938. return default_repository_entry_qs[0]
  939. except IndexError:
  940. pass
  941. # Return the entry in the repository with the highest position number
  942. try:
  943. return self.repository_entries.order_by('-repository__position')[0]
  944. except IndexError:
  945. return None
  946. def get_changelog_entry(self):
  947. """
  948. Retrieve the changelog entry which corresponds to this package version.
  949. If there is no changelog associated with the version returns ``None``
  950. :rtype: :class:`string` or ``None``
  951. """
  952. # If there is no changelog, return immediately
  953. try:
  954. extracted_changelog = \
  955. self.extracted_source_files.get(name='changelog')
  956. except ExtractedSourceFile.DoesNotExist:
  957. return
  958. extracted_changelog.extracted_file.open()
  959. # Let the File context manager close the file
  960. with extracted_changelog.extracted_file as changelog_file:
  961. changelog_content = changelog_file.read()
  962. changelog = debian_changelog.Changelog(changelog_content.splitlines())
  963. # Return the entry corresponding to the package version, or ``None``
  964. return next((
  965. force_text(entry).strip()
  966. for entry in changelog
  967. if entry.version == self.version),
  968. None)
  969. def update(self, **kwargs):
  970. """
  971. The method updates all of the instance attributes based on the keyword
  972. arguments.
  973. >>> src_pkg = SourcePackage()
  974. >>> src_pkg.update(version='1.0.0', homepage='http://example.com')
  975. >>> str(src_pkg.version)
  976. '1.0.0'
  977. >>> str(src_pkg.homepage)
  978. 'http://example.com'
  979. """
  980. for key, value in kwargs.items():
  981. if hasattr(self, key):
  982. setattr(self, key, value)
  983. @python_2_unicode_compatible
  984. class BinaryPackage(models.Model):
  985. """
  986. The method represents a particular binary package.
  987. All information regarding a (binary-package-name, version) which is
  988. independent from the repository in which the package is found.
  989. """
  990. binary_package_name = models.ForeignKey(
  991. BinaryPackageName,
  992. related_name='binary_package_versions'
  993. )
  994. version = models.CharField(max_length=100)
  995. source_package = models.ForeignKey(SourcePackage)
  996. short_description = models.CharField(max_length=300, blank=True)
  997. long_description = models.TextField(blank=True)
  998. class Meta:
  999. unique_together = ('binary_package_name', 'version')
  1000. def __str__(self):
  1001. return 'Binary package {pkg}, version {ver}'.format(
  1002. pkg=self.binary_package_name, ver=self.version)
  1003. def update(self, **kwargs):
  1004. """
  1005. The method updates all of the instance attributes based on the keyword
  1006. arguments.
  1007. """
  1008. for key, value in kwargs.items():
  1009. if hasattr(self, key):
  1010. setattr(self, key, value)
  1011. @cached_property
  1012. def name(self):
  1013. """Returns the name of the package"""
  1014. return self.binary_package_name.name
  1015. class BinaryPackageRepositoryEntryManager(models.Manager):
  1016. def filter_by_package_name(self, names):
  1017. """
  1018. :returns: A set of :class:`BinaryPackageRepositoryEntry` instances
  1019. which are associated to a binary package with one of the names
  1020. given in the ``names`` parameter.
  1021. :rtype: :class:`QuerySet <django.db.models.query.QuerySet>`
  1022. """
  1023. return self.filter(binary_package__binary_package_name__name__in=names)
  1024. @python_2_unicode_compatible
  1025. class BinaryPackageRepositoryEntry(models.Model):
  1026. """
  1027. A model representing repository specific information for a given binary
  1028. package.
  1029. It links a :class:`BinaryPackage` instance with the :class:`Repository`
  1030. instance.
  1031. """
  1032. binary_package = models.ForeignKey(
  1033. BinaryPackage,
  1034. related_name='repository_entries'
  1035. )
  1036. repository = models.ForeignKey(
  1037. Repository,
  1038. related_name='binary_entries'
  1039. )
  1040. architecture = models.ForeignKey(Architecture)
  1041. priority = models.CharField(max_length=50, blank=True)
  1042. section = models.CharField(max_length=50, blank=True)
  1043. objects = BinaryPackageRepositoryEntryManager()
  1044. class Meta:
  1045. unique_together = ('binary_package', 'repository', 'architecture')
  1046. def __str__(self):
  1047. return '{pkg} ({arch}) in the repository {repo}'.format(
  1048. pkg=self.binary_package, arch=self.architecture,
  1049. repo=self.repository)
  1050. @property
  1051. def name(self):
  1052. """The name of the binary package"""
  1053. return self.binary_package.name
  1054. @cached_property
  1055. def version(self):
  1056. """The version of the binary package"""
  1057. return self.binary_package.version
  1058. class SourcePackageRepositoryEntryManager(models.Manager):
  1059. def filter_by_package_name(self, names):
  1060. """
  1061. :returns: A set of :class:`SourcePackageRepositoryEntry` instances
  1062. which are associated to a source package with one of the names
  1063. given in the ``names`` parameter.
  1064. :rtype: :class:`QuerySet <django.db.models.query.QuerySet>`
  1065. """
  1066. return self.filter(source_package__source_package_name__name__in=names)
  1067. @python_2_unicode_compatible
  1068. class SourcePackageRepositoryEntry(models.Model):
  1069. """
  1070. A model representing source package data that is repository specific.
  1071. It links a :class:`SourcePackage` instance with the :class:`Repository`
  1072. instance.
  1073. """
  1074. source_package = models.ForeignKey(
  1075. SourcePackage,
  1076. related_name='repository_entries'
  1077. )
  1078. repository = models.ForeignKey(Repository, related_name='source_entries')
  1079. priority = models.CharField(max_length=50, blank=True)
  1080. section = models.CharField(max_length=50, blank=True)
  1081. objects = SourcePackageRepositoryEntryManager()
  1082. class Meta:
  1083. unique_together = ('source_package', 'repository')
  1084. def __str__(self):
  1085. return "Source package {pkg} in the repository {repo}".format(
  1086. pkg=self.source_package,
  1087. repo=self.repository)
  1088. @property
  1089. def dsc_file_url(self):
  1090. """
  1091. Returns the URL where the .dsc file of this entry can be found.
  1092. :rtype: string
  1093. """
  1094. if self.source_package.directory and self.source_package.dsc_file_name:
  1095. base_url = self.repository.public_uri.rstrip('/') or \
  1096. self.repository.uri.rstrip('/')
  1097. return '/'.join((
  1098. base_url,
  1099. self.source_package.directory,
  1100. self.source_package.dsc_file_name,
  1101. ))
  1102. else:
  1103. return None
  1104. @property
  1105. def directory_url(self):
  1106. """
  1107. Returns the URL of the package's directory.
  1108. :rtype: string
  1109. """
  1110. if self.source_package.directory:
  1111. base_url = self.repository.public_uri.rstrip('/') or \
  1112. self.repository.uri.rstrip('/')
  1113. return base_url + '/' + self.source_package.directory
  1114. else:
  1115. return None
  1116. @property
  1117. def name(self):
  1118. """The name of the source package"""
  1119. return self.source_package.name
  1120. @cached_property
  1121. def version(self):
  1122. """
  1123. Returns the version of the associated source package.
  1124. """
  1125. return self.source_package.version
  1126. def _extracted_source_file_upload_path(instance, filename):
  1127. return '/'.join((
  1128. 'packages',
  1129. package_hashdir(instance.source_package.name),
  1130. instance.source_package.name,
  1131. os.path.basename(filename) + '-' + instance.source_package.version
  1132. ))
  1133. @python_2_unicode_compatible
  1134. class ExtractedSourceFile(models.Model):
  1135. """
  1136. Model representing a single file extracted from a source package archive.
  1137. """
  1138. source_package = models.ForeignKey(
  1139. SourcePackage,
  1140. related_name='extracted_source_files')
  1141. extracted_file = models.FileField(
  1142. upload_to=_extracted_source_file_upload_path)
  1143. name = models.CharField(max_length=100)
  1144. date_extracted = models.DateTimeField(auto_now_add=True)
  1145. class Meta:
  1146. unique_together = ('source_package', 'name')
  1147. def __str__(self):
  1148. return 'Extracted file {extracted_file} of package {package}'.format(
  1149. extracted_file=self.extracted_file, package=self.source_package)
  1150. @python_2_unicode_compatible
  1151. class PackageExtractedInfo(models.Model):
  1152. """
  1153. A model representing a quasi key-value store for package information
  1154. extracted from other models in order to speed up its rendering on
  1155. Web pages.
  1156. """
  1157. package = models.ForeignKey(PackageName)
  1158. key = models.CharField(max_length=50)
  1159. value = JSONField()
  1160. def __str__(self):
  1161. return '{key}: {value} for package {package}'.format(
  1162. key=self.key, value=self.value, package=self.package)
  1163. class Meta:
  1164. unique_together = ('key', 'package')
  1165. class MailingListManager(models.Manager):
  1166. """
  1167. A custom :class:`Manager <django.db.models.Manager>` for the
  1168. :class:`MailingList` class.
  1169. """
  1170. def get_by_email(self, email):
  1171. """
  1172. Returns a :class:`MailingList` instance which matches the given email.
  1173. This means that the email's domain matches exactly the MailingList's
  1174. domain field.
  1175. """
  1176. if '@' not in email:
  1177. return None
  1178. domain = email.rsplit('@', 1)[1]
  1179. qs = self.filter(domain=domain)
  1180. if qs.exists():
  1181. return qs[0]
  1182. else:
  1183. return None
  1184. def validate_archive_url_template(value):
  1185. """
  1186. Custom validator for :class:`MailingList`'s
  1187. :attr:`archive_url_template <MailingList.archive_url_template>` field.
  1188. :raises ValidationError: If there is no {user} parameter in the value.
  1189. """
  1190. if '{user}' not in value:
  1191. raise ValidationError(
  1192. "The archive URL template must have a {user} parameter")
  1193. @python_2_unicode_compatible
  1194. class MailingList(models.Model):
  1195. """
  1196. Describes a known mailing list.
  1197. This provides Distro Tracker users to define the known mailing lists
  1198. through the admin panel in order to support displaying their archives in the
  1199. package pages without modifying any code.
  1200. Instances should have the :attr:`archive_url_template` field set to the
  1201. template which archive URLs should follow where a mandatory parameter is
  1202. {user}.
  1203. """
  1204. name = models.CharField(max_length=100)
  1205. domain = models.CharField(max_length=255, unique=True)
  1206. archive_url_template = models.CharField(max_length=255, validators=[
  1207. validate_archive_url_template,
  1208. ])
  1209. objects = MailingListManager()
  1210. def __str__(self):
  1211. return self.name
  1212. def archive_url(self, user):
  1213. """
  1214. Returns the archive URL for the given user.
  1215. :param user: The user for whom the archive URL should be returned
  1216. :type user: string
  1217. :rtype: string
  1218. """
  1219. return self.archive_url_template.format(user=user)
  1220. def archive_url_for_email(self, email):
  1221. """
  1222. Returns the archive URL for the given email.
  1223. Similar to :meth:`archive_url`, but extracts the user name from the
  1224. email first.
  1225. :param email: The email of the user for whom the archive URL should be
  1226. returned
  1227. :type user: string
  1228. :rtype: string
  1229. """
  1230. if '@' not in email:
  1231. return None
  1232. user, domain = email.rsplit('@', 1)
  1233. if domain != self.domain:
  1234. return None
  1235. return self.archive_url(user)
  1236. @python_2_unicode_compatible
  1237. class RunningJob(models.Model):
  1238. """
  1239. A model used to serialize a running job state, i.e. instances of the
  1240. :class:`JobState <distro_tracker.core.tasks.JobState>` class.
  1241. """
  1242. datetime_created = models.DateTimeField(auto_now_add=True)
  1243. initial_task_name = models.CharField(max_length=50)
  1244. additional_parameters = JSONField(null=True)
  1245. state = JSONField(null=True)
  1246. is_complete = models.BooleanField(default=False)
  1247. def __str__(self):
  1248. if self.is_complete:
  1249. return "Completed Job (started {date})".format(
  1250. date=self.datetime_created)
  1251. else:
  1252. return "Running Job (started {date})".format(
  1253. date=self.datetime_created)
  1254. class NewsManager(models.Manager):
  1255. """
  1256. A custom :class:`Manager <django.db.models.Manager>` for the
  1257. :class:`News` model.
  1258. """
  1259. def create(self, **kwargs):
  1260. """
  1261. Overrides the default create method to allow for easier creation of
  1262. News with different content backing (DB or file).
  1263. If there is a ``content`` parameter in the kwargs, the news content is
  1264. saved to the database.
  1265. If there is a ``file_content`` parameter in the kwargs, the news content
  1266. is saved to a file.
  1267. If none of those parameters are given, the method works as expected.
  1268. """
  1269. if 'content' in kwargs:
  1270. db_content = kwargs.pop('content')
  1271. kwargs['_db_content'] = db_content
  1272. if 'file_content' in kwargs:
  1273. file_content = kwargs.pop('file_content')
  1274. kwargs['news_file'] = ContentFile(file_content, name='news-file')
  1275. return super(NewsManager, self).create(**kwargs)
  1276. def news_upload_path(instance, filename):
  1277. return '/'.join((
  1278. 'news',
  1279. package_hashdir(instance.package.name),
  1280. instance.package.name,
  1281. filename
  1282. ))
  1283. @python_2_unicode_compatible
  1284. class News(models.Model):
  1285. """
  1286. A model used to describe a news item regarding a package.
  1287. """
  1288. package = models.ForeignKey(PackageName)
  1289. title = models.CharField(max_length=255)
  1290. content_type = models.CharField(max_length=100, default='text/plain')
  1291. _db_content = models.TextField(blank=True, null=True)
  1292. news_file = models.FileField(upload_to=news_upload_path, blank=True)
  1293. created_by = models.CharField(max_length=100, blank=True)
  1294. datetime_created = models.DateTimeField(auto_now_add=True)
  1295. signed_by = models.ManyToManyField(
  1296. ContributorName,
  1297. related_name='signed_news_set')
  1298. objects = NewsManager()
  1299. def __str__(self):
  1300. return self.title
  1301. @cached_property
  1302. def content(self):
  1303. """
  1304. Returns either the content of the message saved in the database or
  1305. retrieves it from the news file found in the filesystem.
  1306. The property is cached so that a single instance of :class:`News` does
  1307. not have to read a file every time its content is accessed.
  1308. """
  1309. if self._db_content:
  1310. return self._db_content
  1311. elif self.news_file:
  1312. self.news_file.open('r')
  1313. content = self.news_file.read()
  1314. self.news_file.close()
  1315. return content
  1316. def save(self, *args, **kwargs):
  1317. super(News, self).save(*args, **kwargs)
  1318. signers = verify_signature(self.get_signed_content())
  1319. if signers is None:
  1320. # No signature
  1321. return
  1322. signed_by = []
  1323. for name, email in signers:
  1324. signer_email, _ = UserEmail.objects.get_or_create(
  1325. email=email)
  1326. signer_name, _ = ContributorName.objects.get_or_create(
  1327. name=name,
  1328. contributor_email=signer_email)
  1329. signed_by.append(signer_name)
  1330. self.signed_by = signed_by
  1331. def get_signed_content(self):
  1332. return self.content
  1333. def get_absolute_url(self):
  1334. return reverse('dtracker-news-page', kwargs={
  1335. 'news_id': self.pk,
  1336. })
  1337. class EmailNewsManager(NewsManager):
  1338. """
  1339. A custom :class:`Manager <django.db.models.Manager>` for the
  1340. :class:`EmailNews` model.
  1341. """
  1342. def create_email_news(self, message, package, **kwargs):
  1343. """
  1344. The method creates a news item from the given email message.
  1345. If a title of the message is not given, it automatically generates it
  1346. based on the sender of the email.
  1347. :param message: The message based on which a news item should be
  1348. created.
  1349. :type message: :class:`Message <email.message.Message>`
  1350. :param package: The package to which the news item refers
  1351. :type: :class:`PackageName`
  1352. """
  1353. create_kwargs = EmailNews.get_email_news_parameters(message)
  1354. # The parameters given to the method directly by the client have
  1355. # priority over what is extracted from the email message.
  1356. create_kwargs.update(kwargs)
  1357. return self.create(package=package, **create_kwargs)
  1358. def get_queryset(self):
  1359. return super(EmailNewsManager, self).get_queryset().filter(
  1360. content_type='message/rfc822')
  1361. class EmailNews(News):
  1362. objects = EmailNewsManager()
  1363. class Meta:
  1364. proxy = True
  1365. def get_signed_content(self):
  1366. msg = message_from_bytes(self.content)
  1367. return get_decoded_message_payload(msg)
  1368. @staticmethod
  1369. def get_email_news_parameters(message):
  1370. """
  1371. Returns a dict representing default values for some :class:`EmailNews`
  1372. fields based on the given email message.
  1373. """
  1374. kwargs = {}
  1375. from_email = decode_header(message.get('From', 'unknown'))
  1376. kwargs['created_by'], _ = parseaddr(from_email)
  1377. if 'Subject' in message:
  1378. kwargs['title'] = decode_header(message['Subject'])
  1379. else:
  1380. kwargs['title'] = \
  1381. 'Email news from {sender}'.format(sender=from_email)
  1382. if hasattr(message, 'as_bytes'):
  1383. kwargs['file_content'] = message.as_bytes()
  1384. else:
  1385. kwargs['file_content'] = message.as_string()
  1386. kwargs['content_type'] = 'message/rfc822'
  1387. return kwargs
  1388. class NewsRenderer(six.with_metaclass(PluginRegistry)):
  1389. """
  1390. Base class which is used to register subclasses to render a :class:`News`
  1391. instance's contents into an HTML page.
  1392. Each :class:`News` instance has a :attr:`News.content_type` field which
  1393. is used to select the correct renderer for its type.
  1394. """
  1395. #: Each :class:`NewsRenderer` subclass sets a content type that it can
  1396. #: render into HTML
  1397. content_type = None
  1398. #: A renderer can define a template name which will be included when its
  1399. #: output is required
  1400. template_name = None
  1401. #: The context is made available to the renderer's template, if available.
  1402. #: By default this is only the news instance which should be rendered.
  1403. @property
  1404. def context(self):
  1405. return {
  1406. 'news': self.news
  1407. }
  1408. #: Pure HTML which is included when the renderer's output is required.
  1409. #: Must be marked safe with :func:`django.utils.safestring.mark_safe`
  1410. #: or else it will be HTML encoded!
  1411. html_output = None
  1412. def __init__(self, news):
  1413. """
  1414. :type news: :class:`distro_tracker.core.models.News`
  1415. """
  1416. self.news = news
  1417. @classmethod
  1418. def get_renderer_for_content_type(cls, content_type):
  1419. """
  1420. Returns one of the :class:`NewsRenderer` subclasses which implements
  1421. rendering the given content type. If there is more than one such class,
  1422. it is undefined which one is returned from this method. If there is
  1423. not renderer for the given type, ``None`` is returned.
  1424. :param content_type: The Content-Type for which a renderer class should
  1425. be returned.
  1426. :type content_type: string
  1427. :rtype: :class:`NewsRenderer` subclass or ``None``
  1428. """
  1429. for news_renderer in cls.plugins:
  1430. if news_renderer.content_type == content_type:
  1431. return news_renderer
  1432. return None
  1433. def render_to_string(self):
  1434. """
  1435. :returns: A safe string representing the rendered HTML output.
  1436. """
  1437. if self.template_name:
  1438. return mark_safe(distro_tracker_render_to_string(
  1439. self.template_name,
  1440. {'ctx': self.context, }))
  1441. elif self.html_output:
  1442. return mark_safe(self.html_output)
  1443. else:
  1444. return ''
  1445. class PlainTextNewsRenderer(NewsRenderer):
  1446. """
  1447. Renders a text/plain content type by placing the text in a <pre> HTML block
  1448. """
  1449. content_type = 'text/plain'
  1450. template_name = 'core/news-plain.html'
  1451. class HtmlNewsRenderer(NewsRenderer):
  1452. """
  1453. Renders a text/html content type by simply emitting it to the output.
  1454. When creating news with a text/html type, you must be careful to properly
  1455. santize any user-provided data or risk security vulnerabilities.
  1456. """
  1457. content_type = 'text/html'
  1458. @property
  1459. def html_output(self):
  1460. return mark_safe(self.news.content)
  1461. class EmailNewsRenderer(NewsRenderer):
  1462. """
  1463. Renders news content as an email message.
  1464. """
  1465. content_type = 'message/rfc822'
  1466. template_name = 'core/news-email.html'
  1467. @cached_property
  1468. def context(self):
  1469. msg = message_from_bytes(self.news.content)
  1470. # Extract headers first
  1471. DEFAULT_HEADERS = (
  1472. 'From',
  1473. 'To',
  1474. 'Subject',
  1475. )
  1476. EMAIL_HEADERS = (
  1477. 'from',
  1478. 'to',
  1479. 'cc',
  1480. 'bcc',
  1481. 'resent-from',
  1482. 'resent-to',
  1483. 'resent-cc',
  1484. 'resent-bcc',
  1485. )
  1486. USER_DEFINED_HEADERS = getattr(settings,
  1487. 'DISTRO_TRACKER_EMAIL_NEWS_HEADERS', ())
  1488. ALL_HEADERS = [
  1489. header.lower()
  1490. for header in DEFAULT_HEADERS + USER_DEFINED_HEADERS
  1491. ]
  1492. headers = {}
  1493. for header_name, header_value in msg.items():
  1494. if header_name.lower() not in ALL_HEADERS:
  1495. continue
  1496. header_value = decode_header(header_value)
  1497. if header_name.lower() in EMAIL_HEADERS:
  1498. headers[header_name] = {
  1499. 'emails': [
  1500. {
  1501. 'email': email,
  1502. 'name': name,
  1503. }
  1504. for name, email in getaddresses([header_value])
  1505. ]
  1506. }
  1507. if header_name.lower() == 'from':
  1508. from_name = headers[header_name]['emails'][0]['name']
  1509. else:
  1510. headers[header_name] = {'value': header_value}
  1511. signers = list(self.news.signed_by.select_related())
  1512. if signers and signers[0].name == from_name:
  1513. signers = []
  1514. plain_text_payloads = []
  1515. for part in typed_subpart_iterator(msg, 'text', 'plain'):
  1516. message = linkify(escape(get_decoded_message_payload(part)))
  1517. plain_text_payloads.append(message)
  1518. return {
  1519. 'headers': headers,
  1520. 'parts': plain_text_payloads,
  1521. 'signed_by': signers,
  1522. }
  1523. @python_2_unicode_compatible
  1524. class PackageBugStats(models.Model):
  1525. """
  1526. Model for bug statistics of source and pseudo packages (packages modelled
  1527. by the :class:`PackageName` model).
  1528. """
  1529. package = models.OneToOneField(PackageName, related_name='bug_stats')
  1530. stats = JSONField(blank=True)
  1531. def __str__(self):
  1532. return '{package} bug stats: {stats}'.format(
  1533. package=self.package, stats=self.stats)
  1534. @python_2_unicode_compatible
  1535. class BinaryPackageBugStats(models.Model):
  1536. """
  1537. Model for bug statistics of binary packages (:class:`BinaryPackageName`).
  1538. """
  1539. package = models.OneToOneField(BinaryPackageName,
  1540. related_name='binary_bug_stats')
  1541. stats = JSONField(blank=True)
  1542. def __str__(self):
  1543. return '{package} bug stats: {stats}'.format(
  1544. package=self.package, stats=self.stats)
  1545. class ActionItemTypeManager(models.Manager):
  1546. """
  1547. A custom :class:`Manager <django.db.models.Manager>` for the
  1548. :class:`ActionItemType` model.
  1549. """
  1550. def create_or_update(self, type_name, full_description_template):
  1551. """
  1552. Method either creates the template with the given name and description
  1553. template or makes sure to update an existing instance of that name
  1554. to have the given template.
  1555. :param type_name: The name of the :class:`ActionItemType` instance to
  1556. create.
  1557. :type type_name: string
  1558. :param full_description_template: The description template that the
  1559. returned :class:`ActionItemType` instance should have.
  1560. :type full_description_template: string
  1561. :returns: :class:`ActionItemType` instance
  1562. """
  1563. item_type, created = self.get_or_create(type_name=type_name, defaults={
  1564. 'full_description_template': full_description_template
  1565. })
  1566. if created:
  1567. return item_type
  1568. # If it wasn't just created check if the template needs to be updated
  1569. if item_type.full_description_template != full_description_template:
  1570. item_type.full_description_template = full_description_template
  1571. item_type.save()
  1572. return item_type
  1573. @python_2_unicode_compatible
  1574. class ActionItemType(models.Model):
  1575. type_name = models.TextField(max_length=100, unique=True)
  1576. full_description_template = models.CharField(
  1577. max_length=255, blank=True, null=True)
  1578. objects = ActionItemTypeManager()
  1579. def __str__(self):
  1580. return self.type_name
  1581. class ActionItemManager(models.Manager):
  1582. """
  1583. A custom :class:`Manager <django.db.models.Manager>` for the
  1584. :class:`ActionItem` model.
  1585. """
  1586. def delete_obsolete_items(self, item_types, non_obsolete_packages):
  1587. """
  1588. The method removes :class:`ActionItem` instances which have one of the
  1589. given types and are not associated to one of the non obsolete packages.
  1590. :param item_types: A list of action item types to be considered for
  1591. removal.
  1592. :type item_types: list of :class:`ActionItemType` instances
  1593. :param non_obsolete_packages: A list of package names whose items are
  1594. not to be removed.
  1595. :type non_obsolete_packages: list of strings
  1596. """
  1597. if len(item_types) == 1:
  1598. qs = self.filter(item_type=item_types[0])
  1599. else:
  1600. qs = self.filter(item_type__in=item_types)
  1601. qs = qs.exclude(package__name__in=non_obsolete_packages)
  1602. qs.delete()
  1603. @python_2_unicode_compatible
  1604. class ActionItem(models.Model):
  1605. """
  1606. Model for entries of the "action needed" panel.
  1607. """
  1608. #: All available severity levels
  1609. SEVERITY_WISHLIST = 0
  1610. SEVERITY_LOW = 1
  1611. SEVERITY_NORMAL = 2
  1612. SEVERITY_HIGH = 3
  1613. SEVERITY_CRITICAL = 4
  1614. SEVERITIES = (
  1615. (SEVERITY_WISHLIST, 'wishlist'),
  1616. (SEVERITY_LOW, 'low'),
  1617. (SEVERITY_NORMAL, 'normal'),
  1618. (SEVERITY_HIGH, 'high'),
  1619. (SEVERITY_CRITICAL, 'critical'),
  1620. )
  1621. package = models.ForeignKey(PackageName, related_name='action_items')
  1622. item_type = models.ForeignKey(ActionItemType, related_name='action_items')
  1623. short_description = models.TextField()
  1624. severity = models.IntegerField(choices=SEVERITIES, default=SEVERITY_NORMAL)
  1625. created_timestamp = models.DateTimeField(auto_now_add=True)
  1626. last_updated_timestamp = models.DateTimeField(auto_now=True)
  1627. extra_data = JSONField(blank=True, null=True)
  1628. objects = ActionItemManager()
  1629. class Meta:
  1630. unique_together = ('package', 'item_type')
  1631. def __str__(self):
  1632. return '{package} - {desc} ({severity})'.format(
  1633. package=self.package,
  1634. desc=self.short_description,
  1635. severity=self.get_severity_display())
  1636. def get_absolute_url(self):
  1637. return reverse('dtracker-action-item', kwargs={
  1638. 'item_pk': self.pk,
  1639. })
  1640. @property
  1641. def full_description_template(self):
  1642. return self.item_type.full_description_template
  1643. @cached_property
  1644. def full_description(self):
  1645. if not self.full_description_template:
  1646. return ''
  1647. try:
  1648. return mark_safe(
  1649. distro_tracker_render_to_string(
  1650. self.full_description_template,
  1651. {'item': self, }))
  1652. except:
  1653. return ''
  1654. def to_dict(self):
  1655. return {
  1656. 'short_description': self.short_description,
  1657. 'package': {
  1658. 'name': self.package.name,
  1659. 'id': self.package.id,
  1660. },
  1661. 'full_description': self.full_description,
  1662. 'severity': {
  1663. 'name': self.get_severity_display(),
  1664. 'level': self.severity,
  1665. 'label_type': {
  1666. 0: 'info',
  1667. 3: 'warning',
  1668. 4: 'danger',
  1669. }.get(self.severity, 'default')
  1670. },
  1671. 'created': self.created_timestamp.strftime('%Y-%m-%d'),
  1672. 'updated': self.last_updated_timestamp.strftime('%Y-%m-%d'),
  1673. }
  1674. class ConfirmationException(Exception):
  1675. """
  1676. An exception which is raised when the :py:class:`ConfirmationManager`
  1677. is unable to generate a unique key for a given identifier.
  1678. """
  1679. pass
  1680. class ConfirmationManager(models.Manager):
  1681. """
  1682. A custom manager for the :py:class:`Confirmation` model.
  1683. """
  1684. def generate_key(self, identifier):
  1685. """
  1686. Generates a random key for the given identifier.
  1687. :param identifier: A string representation of an identifier for the
  1688. confirmation instance.
  1689. """
  1690. chars = string.ascii_letters + string.digits
  1691. random_string = ''.join(random.choice(chars) for _ in range(16))
  1692. random_string = random_string.encode('ascii')
  1693. salt = hashlib.sha1(random_string).hexdigest()
  1694. hash_input = (salt + identifier).encode('ascii')
  1695. return hashlib.sha1(hash_input).hexdigest()
  1696. def create_confirmation(self, identifier='', **kwargs):
  1697. """
  1698. Creates a :py:class:`Confirmation` object with the given identifier and
  1699. all the given keyword arguments passed.
  1700. :param identifier: A string representation of an identifier for the
  1701. confirmation instance.
  1702. :raises distro_tracker.mail.models.ConfirmationException: If it is
  1703. unable to generate a unique key.
  1704. """
  1705. MAX_TRIES = 10
  1706. errors = 0
  1707. while errors < MAX_TRIES:
  1708. confirmation_key = self.generate_key(identifier)
  1709. try:
  1710. return self.create(confirmation_key=confirmation_key, **kwargs)
  1711. except IntegrityError:
  1712. errors += 1
  1713. raise ConfirmationException(
  1714. 'Unable to generate a confirmation key for {identifier}'.format(
  1715. identifier=identifier))
  1716. def clean_up_expired(self):
  1717. """
  1718. Removes all expired confirmation keys.
  1719. """
  1720. for confirmation in self.all():
  1721. if confirmation.is_expired():
  1722. confirmation.delete()
  1723. def get(self, *args, **kwargs):
  1724. """
  1725. Overrides the default :py:class:`django.db.models.Manager` method so
  1726. that expired :py:class:`Confirmation` instances are never
  1727. returned.
  1728. :rtype: :py:class:`Confirmation` or ``None``
  1729. """
  1730. instance = super(ConfirmationManager, self).get(*args, **kwargs)
  1731. return instance if not instance.is_expired() else None
  1732. @python_2_unicode_compatible
  1733. class Confirmation(models.Model):
  1734. """
  1735. An abstract model allowing its subclasses to store and create confirmation
  1736. keys.
  1737. """
  1738. confirmation_key = models.CharField(max_length=40, unique=True)
  1739. date_created = models.DateTimeField(auto_now_add=True)
  1740. objects = ConfirmationManager()
  1741. class Meta:
  1742. abstract = True
  1743. def __str__(self):
  1744. return self.confirmation_key
  1745. def is_expired(self):
  1746. """
  1747. :returns True: if the confirmation key has expired
  1748. :returns False: if the confirmation key is still valid
  1749. """
  1750. delta = timezone.now() - self.date_created
  1751. return delta.days >= DISTRO_TRACKER_CONFIRMATION_EXPIRATION_DAYS
  1752. @python_2_unicode_compatible
  1753. class SourcePackageDeps(models.Model):
  1754. source = models.ForeignKey(SourcePackageName,
  1755. related_name='source_dependencies')
  1756. dependency = models.ForeignKey(SourcePackageName,
  1757. related_name='source_dependents')
  1758. repository = models.ForeignKey(Repository)
  1759. build_dep = models.BooleanField(default=False)
  1760. binary_dep = models.BooleanField(default=False)
  1761. details = JSONField()
  1762. class Meta:
  1763. unique_together = ('source', 'dependency', 'repository')
  1764. def __str__(self):
  1765. return '{} depends on {}'.format(self.source, self.dependency)
  1766. class TeamManager(models.Manager):
  1767. """
  1768. A custom :class:`Manager <django.db.models.Manager>` for the
  1769. :class:`Team` model.
  1770. """
  1771. def create_with_slug(self, **kwargs):
  1772. """
  1773. A variant of the create method which automatically populates the
  1774. instance's slug field by slugifying the name.
  1775. """
  1776. if 'slug' not in kwargs:
  1777. kwargs['slug'] = slugify(kwargs['name'])
  1778. if 'maintainer_email' in kwargs:
  1779. if not isinstance(kwargs['maintainer_email'], UserEmail):
  1780. kwargs['maintainer_email'] = UserEmail.objects.get_or_create(
  1781. email=kwargs['maintainer_email'])[0]
  1782. return self.create(**kwargs)
  1783. @python_2_unicode_compatible
  1784. class Team(models.Model):
  1785. name = models.CharField(max_length=100, unique=True)
  1786. slug = models.SlugField(
  1787. unique=True,
  1788. help_text="A team's slug determines its URL")
  1789. maintainer_email = models.ForeignKey(
  1790. UserEmail,
  1791. null=True,
  1792. blank=True,
  1793. on_delete=models.SET_NULL)
  1794. description = models.TextField(blank=True, null=True)
  1795. url = models.URLField(max_length=255, blank=True, null=True)
  1796. public = models.BooleanField(default=True)
  1797. owner = models.ForeignKey(
  1798. 'accounts.User',
  1799. null=True,
  1800. on_delete=models.SET_NULL,
  1801. related_name='owned_teams')
  1802. packages = models.ManyToManyField(
  1803. PackageName,
  1804. related_name='teams')
  1805. members = models.ManyToManyField(
  1806. UserEmail,
  1807. related_name='teams',
  1808. through='TeamMembership')
  1809. objects = TeamManager()
  1810. def __str__(self):
  1811. return self.name
  1812. def get_absolute_url(self):
  1813. return reverse('dtracker-team-page', kwargs={
  1814. 'slug': self.slug,
  1815. })
  1816. def add_members(self, users, muted=False):
  1817. """
  1818. Adds the given users to the team.
  1819. It automatically creates the intermediary :class:`TeamMembership`
  1820. models.
  1821. :param users: The users to be added to the team.
  1822. :type users: an ``iterable`` of :class:`UserEmail` instances
  1823. :param muted: If set to True, the membership will be muted before the
  1824. user excplicitely unmutes it.
  1825. :type active: bool
  1826. :returns: :class:`TeamMembership` instances for each user added to
  1827. the team
  1828. :rtype: list
  1829. """
  1830. users = [
  1831. user
  1832. if isinstance(user, UserEmail) else
  1833. UserEmail.objects.get_or_create(email=user)[0]
  1834. for user in users
  1835. ]
  1836. return [
  1837. self.team_membership_set.create(user_email=user, muted=muted)
  1838. for user in users
  1839. ]
  1840. def remove_members(self, users):
  1841. """
  1842. Removes the given users from the team.
  1843. :param users: The users to be removed from the team.
  1844. :type users: an ``iterable`` of :class:`UserEmail` instances
  1845. """
  1846. self.team_membership_set.filter(user_email__in=users).delete()
  1847. def user_is_member(self, user):
  1848. """
  1849. Checks whether the given user is a member of the team.
  1850. :param user: The user which should be checked for membership
  1851. :type user: :class:`distro_tracker.accounts.models.User`
  1852. """
  1853. return (
  1854. user == self.owner or
  1855. self.members.filter(pk__in=user.emails.all()).exists()
  1856. )
  1857. @python_2_unicode_compatible
  1858. class TeamMembership(models.Model):
  1859. """
  1860. Represents the intermediary model for the many-to-many association of
  1861. team members to a :class:`Team`.
  1862. """
  1863. user_email = models.ForeignKey(UserEmail, related_name='membership_set')
  1864. team = models.ForeignKey(Team, related_name='team_membership_set')
  1865. muted = models.BooleanField(default=False)
  1866. default_keywords = models.ManyToManyField(Keyword)
  1867. has_membership_keywords = models.BooleanField(default=False)
  1868. class Meta:
  1869. unique_together = ('user_email', 'team')
  1870. def __str__(self):
  1871. return '{} member of {}'.format(self.user_email, self.team)
  1872. def is_muted(self, package_name):
  1873. """
  1874. Checks if the given package is muted in the team membership.
  1875. A package is muted if the team membership itself is muted as a whole or
  1876. if :class:`MembershipPackageSpecifics` for the package indicates that
  1877. the package is muted.
  1878. :param package_name: The name of the package.
  1879. :type package_name: :class:`PackageName` or :class:`str`
  1880. """
  1881. if not isinstance(package_name, PackageName):
  1882. package_name = PackageName.objects.get(package_name)
  1883. if self.muted:
  1884. return True
  1885. try:
  1886. package_specifics = self.membership_package_specifics.get(
  1887. package_name=package_name)
  1888. except MembershipPackageSpecifics.DoesNotExist:
  1889. return False
  1890. return package_specifics.muted
  1891. def set_mute_package(self, package_name, mute):
  1892. """
  1893. Sets whether the given package should be considered muted for the team
  1894. membership.
  1895. """
  1896. if not isinstance(package_name, PackageName):
  1897. package_name = PackageName.objects.get(package_name)
  1898. package_specifics, _ = self.membership_package_specifics.get_or_create(
  1899. package_name=package_name)
  1900. package_specifics.muted = mute
  1901. package_specifics.save()
  1902. def mute_package(self, package_name):
  1903. """
  1904. The method mutes only the given package in the user's team membership.
  1905. :param package_name: The name of the package.
  1906. :type package_name: :class:`PackageName` or :class:`str`
  1907. """
  1908. self.set_mute_package(package_name, True)
  1909. def unmute_package(self, package_name):
  1910. """
  1911. The method unmutes only the given package in the user's team membership.
  1912. :param package_name: The name of the package.
  1913. :type package_name: :class:`PackageName` or :class:`str`
  1914. """
  1915. self.set_mute_package(package_name, False)
  1916. def set_keywords(self, package_name, keywords):
  1917. """
  1918. Sets the membership-specific keywords for the given package.
  1919. :param package_name: The name of the package for which the keywords
  1920. should be set
  1921. :type package_name: :class:`PackageName` or :class:`str`
  1922. :param keywords: The keywords to be set for the membership-specific
  1923. keywords for the given package.
  1924. :type keywords: an ``iterable`` of keyword names - as strings
  1925. """
  1926. if not isinstance(package_name, PackageName):
  1927. package_name = PackageName.objects.get(package_name)
  1928. new_keywords = Keyword.objects.filter(name__in=keywords)
  1929. membership_package_specifics, _ = (
  1930. self.membership_package_specifics.get_or_create(
  1931. package_name=package_name))
  1932. membership_package_specifics.set_keywords(new_keywords)
  1933. def set_membership_keywords(self, keywords):
  1934. """
  1935. Sets the membership default keywords.
  1936. :param keywords: The keywords to be set for the membership
  1937. :type keywords: an ``iterable`` of keyword names - as strings
  1938. """
  1939. new_keywords = Keyword.objects.filter(name__in=keywords)
  1940. self.default_keywords = new_keywords
  1941. self.has_membership_keywords = True
  1942. self.save()
  1943. def get_keywords(self, package_name):
  1944. """
  1945. Returns the keywords that are associated to a particular package of
  1946. this team membership.
  1947. The first set of keywords that exists in the order given below is
  1948. returned:
  1949. - Membership package-specific keywords
  1950. - Membership default keywords
  1951. - UserEmail default keywords
  1952. :param package_name: The name of the package for which the keywords
  1953. should be returned
  1954. :type package_name: :class:`PackageName` or :class:`str`
  1955. :return: The keywords which should be used when forwarding mail
  1956. regarding the given package to the given user for the team
  1957. membership.
  1958. :rtype: :class:`QuerySet <django.db.models.query.QuerySet>` of
  1959. :class:`Keyword` instances.
  1960. """
  1961. if not isinstance(package_name, PackageName):
  1962. package_name = PackageName.objects.get(package_name)
  1963. try:
  1964. membership_package_specifics = \
  1965. self.membership_package_specifics.get(
  1966. package_name=package_name)
  1967. if membership_package_specifics._has_keywords:
  1968. return membership_package_specifics.keywords.all()
  1969. except MembershipPackageSpecifics.DoesNotExist:
  1970. pass
  1971. if self.has_membership_keywords:
  1972. return self.default_keywords.all()
  1973. email_settings, _ = \
  1974. EmailSettings.objects.get_or_create(user_email=self.user_email)
  1975. return email_settings.default_keywords.all()
  1976. @python_2_unicode_compatible
  1977. class MembershipPackageSpecifics(models.Model):
  1978. """
  1979. Represents a model for keeping information regarding a pair of
  1980. (membership, package) instances.
  1981. """
  1982. membership = models.ForeignKey(
  1983. TeamMembership,
  1984. related_name='membership_package_specifics')
  1985. package_name = models.ForeignKey(PackageName)
  1986. keywords = models.ManyToManyField(Keyword)
  1987. _has_keywords = models.BooleanField(default=False)
  1988. muted = models.BooleanField(default=False)
  1989. class Meta:
  1990. unique_together = ('membership', 'package_name')
  1991. def __str__(self):
  1992. return "Membership ({}) specific keywords for {} package".format(
  1993. self.membership, self.package_name)
  1994. def set_keywords(self, keywords):
  1995. self.keywords = keywords
  1996. self._has_keywords = True
  1997. self.save()
  1998. @python_2_unicode_compatible
  1999. class MembershipConfirmation(Confirmation):
  2000. membership = models.ForeignKey(TeamMembership)
  2001. def __str__(self):
  2002. return "Confirmation for {}".format(self.membership)