1# Copyright 2018 The Distro Tracker Developers
2# See the COPYRIGHT file at the top-level directory of this distribution and
3# at https://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 https://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"""Implements the core package tables shown on team pages."""
11import importlib
12import logging
14from django.conf import settings
15from django.db.models import Prefetch
16from django.template import Context, Template
17from django.template.loader import get_template
19from distro_tracker import vendor
20from distro_tracker.core.models import (
21 BugDisplayManagerMixin,
22 PackageData,
23 PackageName,
24)
25from distro_tracker.core.utils import add_developer_extras, get_vcs_name
26from distro_tracker.core.utils.plugins import PluginRegistry
28logger = logging.getLogger(__name__)
31class BaseTableField(metaclass=PluginRegistry):
32 """
33 A base class representing fields to be displayed on package tables.
35 To create a new field for packages table, users only need to create a
36 subclass and implement the necessary properties and methods.
38 .. note::
39 To make sure the subclass is loaded, make sure to put it in a
40 ``tracker_package_tables`` module at the top level of a Django app.
41 """
43 def context(self, package):
44 """
45 Should return a dictionary representing context variables necessary for
46 the package table field.
47 When the field's template is rendered, it will have access to the values
48 in this dictionary.
49 """
50 return {}
52 @property
53 def column_name():
54 """
55 The column name for the field
56 """
57 return ''
59 @property
60 def template_name(self):
61 """
62 If the field has a corresponding template which is used to render its
63 HTML output, this property should contain the name of this template.
64 """
65 return None
67 def render(self, package, context=None, request=None):
68 """
69 Render the field's HTML output for the given package.
70 """
71 if not hasattr(self, '_template'):
72 self._template = get_template(self.template_name)
73 if context is None:
74 context = {self.slug: self.context(package)}
75 return self._template.render(context, request)
77 @property
78 def prefetch_related_lookups():
79 """
80 Returns a list of lookups to be prefetched along with
81 Table's QuerySet of packages. Elements may be either a String
82 or Prefetch object
83 """
84 return []
87class GeneralInformationTableField(BaseTableField):
88 """
89 This table field displays general information to identify a package.
91 It displays the package's name in the cell and the following information
92 on details popup
93 - name
94 - short description
95 - version (in the default repository)
96 - maintainer
97 - uploaders
98 - architectures
99 - standards version
100 - binaries
101 """
102 column_name = 'Package'
103 slug = 'general'
104 template_name = 'core/package-table-fields/general.html'
105 prefetch_related_lookups = [
106 Prefetch(
107 'data',
108 queryset=PackageData.objects.filter(key='general'),
109 to_attr='general_data'
110 ),
111 Prefetch(
112 'data',
113 queryset=PackageData.objects.filter(key='binaries'),
114 to_attr='binaries_data'
115 ),
116 ]
118 def context(self, package):
119 try:
120 info = package.general_data[0]
121 except IndexError:
122 # There is no general info for the package
123 return {
124 'url': package.get_absolute_url,
125 'name': package.name
126 }
128 general = info.value
129 general['url'] = package.get_absolute_url
131 # Add developer information links and any other vendor-specific extras
132 general = add_developer_extras(general, url_only=True)
134 try:
135 info = package.binaries_data[0]
136 general['binaries'] = info.value
137 except IndexError:
138 general['binaries'] = []
140 return general
143class VcsTableField(BaseTableField):
144 """
145 This table field displays information regarding the package VCS repository.
146 It is customizable to enable vendors to add specific data
147 regarding the package's vcs repository.
149 The default behavior is to display the package's repository type with a
150 (browser) link to it.
152 A vendor can provide a
153 :data:`DISTRO_TRACKER_VCS_TABLE_FIELD_TEMPLATE
154 <distro_tracker.project.local_settings.DISTRO_TRACKER_VCS_TABLE_FIELD_TEMPLATE>`
155 settings value which gives the path to a template which should
156 be used to render the field. It is recommended that this template extends
157 ``core/package-table-fields/vcs.html``, but not mandatory.
158 If a custom
159 :func:`get_vcs_data
160 <distro_tracker.vendor.skeleton.rules.get_vcs_data>`
161 function in order to provide custom data to be displayed in the field.
162 Refer to the function's documentation for the format of the return value.
163 If this function is defined then its return value is simply passed to the
164 template and does not require any special format; the vendor's template can
165 access this value in the ``field.context`` context variable and can use it
166 any way it wants.
168 To avoid performance issues, if :func:`get_vcs_data
169 <distro_tracker.vendor.skeleton.rules.get_vcs_data>` function
170 depends on data from other database tables than packages, the vendor app
171 should also implement the :func:`additional_prefetch_related_lookups
172 <distro_tracker.vendor.skeleton.rules.additional_prefetch_related_lookups>`
173 """
174 column_name = 'VCS'
175 slug = 'vcs'
176 _default_template_name = 'core/package-table-fields/vcs.html'
177 prefetch_related_lookups = [
178 Prefetch(
179 'data',
180 queryset=PackageData.objects.filter(key='general'),
181 to_attr='general_data'
182 )
183 ]
185 @property
186 def template_name(self):
187 return getattr(
188 settings,
189 'DISTRO_TRACKER_VCS_TABLE_FIELD_TEMPLATE',
190 self._default_template_name)
192 def context(self, package):
193 try:
194 info = package.general_data[0]
195 except IndexError:
196 # There is no general info for the package
197 return
199 general = {}
200 if 'vcs' in info.value: 200 ↛ 206line 200 didn't jump to line 206, because the condition on line 200 was never false
201 general['vcs'] = info.value['vcs']
202 # Map the VCS type to its name.
203 shorthand = general['vcs'].get('type', 'Unknown')
204 general['vcs']['full_name'] = get_vcs_name(shorthand)
206 result, implemented = vendor.call(
207 'get_vcs_data', package)
209 if implemented: 209 ↛ 210line 209 didn't jump to line 210, because the condition on line 209 was never true
210 general.update(result)
212 return general
215class ArchiveTableField(BaseTableField):
216 """
217 This table field displays information regarding the package version on
218 archive.
220 It displays the package's version on archive
221 """
222 column_name = 'Archive'
223 slug = 'archive'
224 template_name = 'core/package-table-fields/archive.html'
225 prefetch_related_lookups = [
226 Prefetch(
227 'data',
228 queryset=PackageData.objects.filter(key='general'),
229 to_attr='general_data'
230 ),
231 Prefetch(
232 'data',
233 queryset=PackageData.objects.filter(key='versions'),
234 to_attr='versions'
235 )
236 ]
238 def context(self, package):
239 try:
240 info = package.general_data[0]
241 except IndexError:
242 # There is no general info for the package
243 return
245 general = {}
246 if 'version' in info.value: 246 ↛ 249line 246 didn't jump to line 249, because the condition on line 246 was never false
247 general['version'] = info.value['version']
249 try:
250 info = package.versions[0].value
251 general['default_pool_url'] = info['default_pool_url']
252 except IndexError:
253 # There is no versions info for the package
254 general['default_pool_url'] = '#'
256 return general
259class BugStatsTableField(BaseTableField, BugDisplayManagerMixin):
260 """
261 This table field displays bug statistics for the package.
262 It is customizable to enable vendors to add specific data.
264 The default behavior defined by :class:`BugDisplayManager
265 <distro_tracker.core.models.BugDisplayManager>`
266 is to display the number of bugs for a package. It also
267 shows the bugs categories on popover content.
269 A vendor may provide a custom way of displaying bugs data in
270 packages tables by implementing :func:`get_bug_display_manager_class
271 <distro_tracker.vendor.skeleton.rules.get_bug_display_manager_class>`
272 function in order to provide a custom class to handle the bugs data
273 presentation. Refer to the function's documentation for the format of the
274 return value.
276 To avoid performance issues, if additional database lookups are
277 required to display custom bugs data, the vendor app
278 should also implement the :func:`additional_prefetch_related_lookups
279 <distro_tracker.vendor.skeleton.rules.additional_prefetch_related_lookups>`
280 """
281 column_name = 'Bugs'
282 slug = 'bugs'
283 prefetch_related_lookups = ['bug_stats']
285 @property
286 def template_name(self):
287 return self.bug_manager.table_field_template_name
289 def context(self, package):
290 return self.bug_manager.table_field_context(package)
293class BasePackageTable(metaclass=PluginRegistry):
294 """
295 A base class representing package tables which are displayed on a team page.
297 To include a package table on the team page, users only need to create a
298 subclass and implement the necessary properties and methods.
300 .. note::
301 To make sure the subclass is loaded, make sure to put it in a
302 ``tracker_package_tables`` module at the top level of a Django app.
304 The following vendor-specific functions can be implemented to augment
305 this table:
307 - :func:`get_table_fields
308 <distro_tracker.vendor.skeleton.rules.get_table_fields>`
309 """
311 #: The slug of the table which is used to define its url.
312 #: Must be overriden and set to a unique non-empty value.
313 slug = None
315 def __init__(self, scope, title=None, limit=None, tag=None):
316 """
317 :param scope: a convenient object that can be used to define the list
318 of packages to be displayed on the table. For instance, if you want
319 to consider all the packages of a specific team, you must pass that
320 team through the `scope` attribute to allow the method
321 :meth:`packages` to access it to define the packages to be
322 presented.
323 :param title: a string to be displayed instead of the default title
324 :param limit: an integer that can be used to define the max number of
325 packages to be displayed
326 :param tag: if defined, it is used to display only packages tagged with
327 the informed tag
328 """
329 self.scope = scope
330 self._title = title
331 self.limit = limit
332 self.tag = tag
333 if tag and not tag.startswith('tag:'):
334 self.tag = 'tag:' + tag
336 def context(self):
337 """
338 Should return a dictionary representing context variables necessary for
339 the package table.
340 When the table's template is rendered, it will have access to the values
341 in this dictionary.
342 """
343 return {}
345 @property
346 def default_title(self):
347 """
348 The default title of the table.
349 """
350 return ''
352 @property
353 def title(self):
354 """
355 The title of the table.
356 """
357 if self._title:
358 return self._title
359 elif self.tag:
360 # TODO: need better design
361 data = PackageData.objects.filter(key=self.tag).first()
362 if data and 'table_title' in data.value:
363 return data.value['table_title']
364 return self.default_title
366 @property
367 def relative_url(self, **kwargs):
368 """
369 The relative url for the table.
370 """
371 url = '+table/' + self.slug
372 if self.tag:
373 tag = self.tag[4:]
374 url = url + '?tag=' + tag
375 return url
377 @property
378 def packages_with_prefetch_related(self):
379 """
380 Returns the list of packages with prefetched relationships defined by
381 table fields
382 """
383 attributes_name = set()
384 package_query_set = self.packages
385 for field in self.table_fields:
386 for lookup in field.prefetch_related_lookups:
387 if isinstance(lookup, Prefetch):
388 if lookup.to_attr in attributes_name:
389 continue
390 else:
391 attributes_name.add(lookup.to_attr)
392 package_query_set = package_query_set.prefetch_related(lookup)
394 additional_data, implemented = vendor.call(
395 'additional_prefetch_related_lookups'
396 )
397 if implemented and additional_data: 397 ↛ 398line 397 didn't jump to line 398, because the condition on line 397 was never true
398 for lookup in additional_data:
399 package_query_set = package_query_set.prefetch_related(lookup)
400 return package_query_set
402 @property
403 def packages(self):
404 """
405 Returns the list of packages shown in the table. One may define this
406 based on the scope
407 """
408 return PackageName.objects.all().order_by('name')
410 @property
411 def column_names(self):
412 """
413 Returns a list of column names that will compose the table
414 in the proper order
415 """
416 names = []
417 for field in self.table_fields:
418 names.append(field.column_name)
419 return names
421 @property
422 def default_fields(self):
423 """
424 Returns a list of default :class:`BaseTableField` that will compose the
425 table
426 """
427 return [
428 GeneralInformationTableField,
429 VcsTableField,
430 ArchiveTableField,
431 BugStatsTableField,
432 ]
434 @property
435 def table_fields(self):
436 """
437 Returns the tuple of :class:`BaseTableField` that will compose the
438 table
439 """
440 fields, implemented = vendor.call('get_table_fields', **{
441 'table': self,
442 })
443 if implemented and fields: 443 ↛ 444line 443 didn't jump to line 444, because the condition on line 443 was never true
444 return tuple(fields)
445 else:
446 return tuple(self.default_fields)
448 @property
449 def rows(self):
450 """
451 Returns the content of the table's rows, where each row has the list
452 of :class:`BaseTableField` for each package
453 """
454 rows = []
455 packages = self.packages_with_prefetch_related
456 if self.limit:
457 packages = packages[:self.limit]
459 template = self.get_row_template()
460 fields = [f() for f in self.table_fields]
461 context = {}
463 for package in packages:
464 context['package'] = package
465 for field in fields:
466 context[field.slug] = field.context(package)
467 rows.append(template.render(Context(context)))
469 return rows
471 @property
472 def number_of_packages(self):
473 """
474 Returns the number of packages displayed in the table
475 """
476 if hasattr(self.packages_with_prefetch_related, 'count'): 476 ↛ 479line 476 didn't jump to line 479, because the condition on line 476 was never false
477 return self.packages_with_prefetch_related.count()
478 else:
479 return 0
481 @staticmethod
482 def get_template_content(template_name):
483 with open(get_template(template_name).origin.name) as f:
484 return f.read()
486 def get_row_template(self):
487 template = "<tr scope='row'>\n"
488 for f in self.table_fields:
489 template += "<td class='center' scope='col'>"
490 template += self.get_template_content(f().template_name)
491 template += "</td>\n"
492 template += "</tr>\n"
493 return Template(template)
496def create_table(slug, scope, title=None, limit=None, tag=None):
497 """
498 A helper function to create packages table. The table class is defined
499 through the `slug`. If no children class of
500 :class:`BasePackageTable` exists with the given slug, the function returns
501 `None`.
503 :returns: an instance of the table created with the informed params
504 :rtype: :class:`BasePackageTable`
505 """
506 for app in settings.INSTALLED_APPS:
507 try:
508 module_name = app + '.' + 'tracker_package_tables'
509 importlib.import_module(module_name)
510 except ImportError:
511 # The app does not implement package tables.
512 pass
514 if limit:
515 limit = int(limit)
516 for table_class in BasePackageTable.plugins:
517 if table_class is not BasePackageTable: 517 ↛ 516line 517 didn't jump to line 516, because the condition on line 517 was never false
518 if table_class.slug == slug:
519 return table_class(scope, title=title, limit=limit, tag=tag)
521 return None
524class GeneralTeamPackageTable(BasePackageTable):
525 """
526 This table displays the packages information of a team in a simple fashion.
527 It must receive a :class:`Team <distro_tracker.core.models.Team>` as scope
528 """
529 default_title = "All team packages"
530 slug = 'general'
532 @property
533 def packages(self):
534 """
535 Returns the list of packages shown in the table of a team (scope)
536 """
537 if self.tag:
538 return self.scope.packages.filter(
539 data__key=self.tag).order_by('name')
540 else:
541 return self.scope.packages.all().order_by('name')