diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4d57a1ad7..98249b81e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -68,6 +68,50 @@ v31.0.0 (next) https://github.com/nexB/scancode.io/issues/164 https://github.com/nexB/scancode.io/issues/464 +- Update application Package scanning step to reflect the updates in + scancode-toolkit package scanning. + + - Package data detected from a file are now stored on the + CodebaseResource.package_data field. + - A second processing step is now done after scanning for Package data, where + Package Resources are determined and DiscoveredPackages and + DiscoveredDependencies are created. + + https://github.com/nexB/scancode.io/issues/444 + +- CodebaseResource.name now contains both the bare file name with extension, as + opposed to just the bare file name without extension. + + - Using a name stripped from its extension was something that was not used in + other AboutCode project or tools. + + https://github.com/nexB/scancode.io/issues/467 + +- Add the model DiscoveredDependency. This represents Package dependencies + discovered in a Project. The ``scan_codebase`` and ``scan_packages`` pipelines + have been updated to create DiscoveredDepdendency objects. The Project API has + been updated with new fields: + + - ``dependency_count`` + - The number of DiscoveredDependencies associated with the project. + + - ``discovered_dependency_summary`` + - A mapping that contains following fields: + + - ``total`` + - The number of DiscoveredDependencies associated with the project. + - ``is_runtime`` + - The number of runtime dependencies. + - ``is_optional`` + - The number of optional dependencies. + - ``is_resolved`` + - The number of resolved dependencies. + + These values are also available on the Project view. + https://github.com/nexB/scancode.io/issues/447 + +- The ``dependencies`` field has been removed from the DiscoveredPackage model. + v30.2.0 (2021-12-17) -------------------- diff --git a/scanpipe/api/serializers.py b/scanpipe/api/serializers.py index 07917a709..8e431cf73 100644 --- a/scanpipe/api/serializers.py +++ b/scanpipe/api/serializers.py @@ -26,6 +26,7 @@ from scanpipe.api import ExcludeFromListViewMixin from scanpipe.models import CodebaseResource +from scanpipe.models import DiscoveredDependency from scanpipe.models import DiscoveredPackage from scanpipe.models import Project from scanpipe.models import ProjectError @@ -113,6 +114,7 @@ class ProjectSerializer( input_sources = serializers.JSONField(source="input_sources_list", read_only=True) codebase_resources_summary = serializers.SerializerMethodField() discovered_package_summary = serializers.SerializerMethodField() + discovered_dependency_summary = serializers.SerializerMethodField() class Meta: model = Project @@ -136,8 +138,10 @@ class Meta: "error_count", "resource_count", "package_count", + "dependency_count", "codebase_resources_summary", "discovered_package_summary", + "discovered_dependency_summary", ) exclude_from_list_view = [ @@ -147,8 +151,10 @@ class Meta: "error_count", "resource_count", "package_count", + "dependency_count", "codebase_resources_summary", "discovered_package_summary", + "discovered_dependency_summary", ] def get_codebase_resources_summary(self, project): @@ -163,6 +169,15 @@ def get_discovered_package_summary(self, project): "with_modified_resources": base_qs.exclude(modified_resources=[]).count(), } + def get_discovered_dependency_summary(self, project): + base_qs = project.discovereddependencys + return { + "total": base_qs.count(), + "is_runtime": base_qs.filter(is_runtime=True).count(), + "is_optional": base_qs.filter(is_optional=True).count(), + "is_resolved": base_qs.filter(is_resolved=True).count(), + } + def create(self, validated_data): """ Creates a new `project` with `upload_file` and `pipeline` as optional. @@ -216,6 +231,16 @@ class Meta: "filename", "last_modified_date", "codebase_resources", + "dependencies", + ] + + +class DiscoveredDependencySerializer(serializers.ModelSerializer): + class Meta: + model = DiscoveredDependency + exclude = [ + "id", + "project", ] @@ -257,6 +282,7 @@ def get_model_serializer(model_class): serializer = { CodebaseResource: CodebaseResourceSerializer, DiscoveredPackage: DiscoveredPackageSerializer, + DiscoveredDependency: DiscoveredDependencySerializer, ProjectError: ProjectErrorSerializer, }.get(model_class, None) diff --git a/scanpipe/api/views.py b/scanpipe/api/views.py index 6428f8e27..5ab449690 100644 --- a/scanpipe/api/views.py +++ b/scanpipe/api/views.py @@ -36,6 +36,7 @@ from rest_framework.response import Response from scanpipe.api.serializers import CodebaseResourceSerializer +from scanpipe.api.serializers import DiscoveredDependencySerializer from scanpipe.api.serializers import DiscoveredPackageSerializer from scanpipe.api.serializers import PipelineSerializer from scanpipe.api.serializers import ProjectErrorSerializer @@ -180,6 +181,16 @@ def packages(self, request, *args, **kwargs): return Response(serializer.data) + @action(detail=True) + def dependencies(self, request, *args, **kwargs): + project = self.get_object() + queryset = project.discovereddependencys.all() + + paginated_qs = self.paginate_queryset(queryset) + serializer = DiscoveredDependencySerializer(paginated_qs, many=True) + + return Response(serializer.data) + @action(detail=True) def errors(self, request, *args, **kwargs): project = self.get_object() diff --git a/scanpipe/filters.py b/scanpipe/filters.py index 0513589a1..7d7f67d24 100644 --- a/scanpipe/filters.py +++ b/scanpipe/filters.py @@ -28,6 +28,7 @@ from packageurl.contrib.django.filters import PackageURLFilter from scanpipe.models import CodebaseResource +from scanpipe.models import DiscoveredDependency from scanpipe.models import DiscoveredPackage from scanpipe.models import Project from scanpipe.models import ProjectError @@ -275,6 +276,14 @@ class Meta: ] +class DependencyFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): + class Meta: + model = DiscoveredDependency + fields = [ + "purl", + ] + + class ErrorFilterSet(FilterSetUtilsMixin, django_filters.FilterSet): search = django_filters.CharFilter( label="Search", field_name="message", lookup_expr="icontains" diff --git a/scanpipe/migrations/0019_codebaseresource_package_data_and_more.py b/scanpipe/migrations/0019_codebaseresource_package_data_and_more.py new file mode 100644 index 000000000..d4fa63da3 --- /dev/null +++ b/scanpipe/migrations/0019_codebaseresource_package_data_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 4.0.6 on 2022-08-03 18:36 + +from django.db import migrations, models +import django.db.models.deletion +import scanpipe.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scanpipe', '0018_codebaseresource_tag'), + ] + + operations = [ + migrations.AddField( + model_name='codebaseresource', + name='package_data', + field=models.JSONField(blank=True, default=list, help_text='List of Package data detected from this CodebaseResource'), + ), + migrations.AlterField( + model_name='codebaseresource', + name='name', + field=models.CharField(blank=True, help_text='File or directory name of this resource with its extension.', max_length=255), + ), + migrations.RemoveField( + model_name='discoveredpackage', + name='dependencies', + ), + migrations.CreateModel( + name='DiscoveredDependency', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('purl', models.CharField(help_text='The Package URL of this dependency.', max_length=1024)), + ('extracted_requirement', models.CharField(blank=True, help_text='The version requirements of this dependency.', max_length=64)), + ('scope', models.CharField(blank=True, help_text='The scope of this dependency, how it is used in a project.', max_length=64)), + ('is_runtime', models.BooleanField(default=False)), + ('is_optional', models.BooleanField(default=False)), + ('is_resolved', models.BooleanField(default=False)), + ('dependency_uid', models.CharField(help_text='The unique identifier of this dependency.', max_length=1024)), + ('for_package_uid', models.CharField(blank=True, help_text='The unique identifier of the package this dependency is for.', max_length=1024)), + ('datafile_path', models.CharField(blank=True, help_text='The relative path to the datafile where this dependency was detected from.', max_length=1024)), + ('datasource_id', models.CharField(blank=True, help_text='The identifier for the datafile handler used to obtain this dependency.', max_length=64)), + ('project', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='scanpipe.project')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, scanpipe.models.SaveProjectErrorMixin), + ), + migrations.AddField( + model_name='discoveredpackage', + name='dependencies', + field=models.ManyToManyField(related_name='discovered_packages', to='scanpipe.discovereddependency'), + ), + ] diff --git a/scanpipe/models.py b/scanpipe/models.py index 422bf7f10..568924904 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -54,6 +54,7 @@ import django_rq import redis import requests +from commoncode.fileutils import parent_directory from commoncode.hash import multi_checksums from packageurl import PackageURL from packageurl import normalize_qualifiers @@ -479,6 +480,7 @@ def reset(self, keep_input=True): self.projecterrors, self.runs, self.discoveredpackages, + self.discovereddependencys, self.codebaseresources, ] @@ -845,6 +847,13 @@ def package_count(self): """ return self.discoveredpackages.count() + @cached_property + def dependency_count(self): + """ + Returns the number of dependencies related to this project. + """ + return self.discovereddependencys.count() + @cached_property def error_count(self): """ @@ -852,6 +861,14 @@ def error_count(self): """ return self.projecterrors.count() + @cached_property + def has_single_resource(self): + """ + Return True if we only have a single CodebaseResource associated to this + project, False otherwise. + """ + return self.codebaseresources.count() == 1 + class ProjectRelatedQuerySet(models.QuerySet): def project(self, project): @@ -1248,6 +1265,9 @@ def has_licenses(self): def has_no_licenses(self): return self.filter(licenses=[]) + def has_package_data(self): + return self.filter(package_data__isnull=False) + def licenses_categories(self, categories): return self.json_list_contains( field_name="licenses", @@ -1412,7 +1432,7 @@ class Type(models.TextChoices): name = models.CharField( max_length=255, blank=True, - help_text=_("File or directory name of this resource."), + help_text=_("File or directory name of this resource with its extension."), ) extension = models.CharField( max_length=100, @@ -1466,6 +1486,12 @@ class Compliance(models.TextChoices): ), ) + package_data = models.JSONField( + default=list, + blank=True, + help_text=_("List of Package data detected from this CodebaseResource"), + ) + objects = CodebaseResourceQuerySet.as_manager() class Meta: @@ -1488,11 +1514,14 @@ def from_db(cls, db, field_names, values): return new - def save(self, *args, **kwargs): + def save(self, codebase=None, *args, **kwargs): """ Saves the current resource instance. Injects policies—if the feature is enabled—when the `licenses` field value is changed. + + `codebase` is not used in this context but required for compatibility + with the commoncode.resource.Codebase class API. """ if scanpipe_app.policies_enabled: loaded_licenses = getattr(self, "loaded_licenses", []) @@ -1582,6 +1611,47 @@ def unique_license_expressions(self): """ return sorted(set(self.license_expressions)) + def parent_path(self): + """ + Return the parent path for this CodebaseResource or None. + """ + return parent_directory(self.path, with_trail=False) + + def has_parent(self): + """ + Return True if this CodebaseResource has a parent CodebaseResource or + False otherwise. + """ + parent_path = self.parent_path() + if not parent_path: + return False + if self.project.codebaseresources.filter(path=parent_path).exists(): + return True + return False + + def parent(self, codebase=None): + """ + Return the parent CodebaseResource object for this CodebaseResource or + None. + + `codebase` is not used in this context but required for compatibility + with the commoncode.resource.Codebase class API. + """ + parent_path = self.parent_path() + return parent_path and self.project.codebaseresources.get(path=parent_path) + + def siblings(self, codebase=None): + """ + Return a sequence of sibling Resource objects for this Resource + or an empty sequence. + + `codebase` is not used in this context but required for compatibility + with the commoncode.resource.Codebase class API. + """ + if self.has_parent(): + return self.parent(codebase).children(codebase) + return [] + def descendants(self): """ Returns a QuerySet of descendant CodebaseResource objects using a @@ -1723,13 +1793,11 @@ class DiscoveredPackage( codebase_resources = models.ManyToManyField( "CodebaseResource", related_name="discovered_packages" ) + dependencies = models.ManyToManyField( + "DiscoveredDependency", related_name="discovered_packages" + ) missing_resources = models.JSONField(default=list, blank=True) modified_resources = models.JSONField(default=list, blank=True) - dependencies = models.JSONField( - default=list, - blank=True, - help_text=_("A list of dependencies for this package."), - ) package_uid = models.CharField( max_length=1024, blank=True, @@ -1857,6 +1925,139 @@ def update_from_data(self, package_data, override=False): return updated_fields +class DiscoveredDependencyQuerySet(ProjectRelatedQuerySet): + pass + + +class DiscoveredDependency( + ProjectRelatedModel, + SaveProjectErrorMixin, +): + """ + A project's Discovered Dependencies are records of the dependencies used by + system and application packages discovered in the code under analysis. + """ + + purl = models.CharField( + max_length=1024, + help_text=_("The Package URL of this dependency."), + ) + extracted_requirement = models.CharField( + max_length=64, + blank=True, + help_text=_("The version requirements of this dependency."), + ) + scope = models.CharField( + max_length=64, + blank=True, + help_text=_("The scope of this dependency, how it is used in a project."), + ) + + is_runtime = models.BooleanField(default=False) + is_optional = models.BooleanField(default=False) + is_resolved = models.BooleanField(default=False) + + dependency_uid = models.CharField( + max_length=1024, + help_text=_("The unique identifier of this dependency."), + ) + for_package_uid = models.CharField( + max_length=1024, + blank=True, + help_text=_("The unique identifier of the package this dependency is for."), + ) + datafile_path = models.CharField( + max_length=1024, + blank=True, + help_text=_( + "The relative path to the datafile where this dependency was detected from." + ), + ) + datasource_id = models.CharField( + max_length=64, + blank=True, + help_text=_( + "The identifier for the datafile handler used to obtain this dependency." + ), + ) + + objects = DiscoveredDependencyQuerySet.as_manager() + + def __str__(self): + return self.purl or str(self.uuid) + + def get_absolute_url(self): + return reverse("dependency_detail", args=[self.project_id, self.pk]) + + @cached_property + def packages(self): + """ + Returns the associated discovered_packages QuerySet as a list. + """ + return list(self.discovered_packages.all()) + + @classmethod + def create_from_data(cls, project, dependency_data): + """ + Creates and returns a DiscoveredDependency for a `project` from the + `dependency_data`. + """ + required_fields = ["purl", "dependency_uid"] + missing_values = [ + field_name + for field_name in required_fields + if not dependency_data.get(field_name) + ] + + if missing_values: + message = ( + f"No values for the following required fields: " + f"{', '.join(missing_values)}" + ) + + project.add_error(error=message, model=cls, details=dependency_data) + return + + if "resolved_package" in dependency_data: + dependency_data.pop("resolved_package") + + cleaned_dependency_data = { + field_name: value + for field_name, value in dependency_data.items() + if field_name in DiscoveredDependency.model_fields() and value + } + discovered_dependency = cls(project=project, **cleaned_dependency_data) + discovered_dependency.save() + + return discovered_dependency + + def update_from_data(self, dependency_data): + """ + Update this discovered dependency instance with the provided `dependency_data`. + The `save()` is called only if at least one field was modified. + """ + model_fields = DiscoveredDependency.model_fields() + updated_fields = [] + + for field_name, value in dependency_data.items(): + skip_reasons = [ + not value, + field_name not in model_fields, + ] + if any(skip_reasons): + continue + + current_value = getattr(self, field_name, None) + if not current_value or current_value != value: + setattr(self, field_name, value) + updated_fields.append(field_name) + + if updated_fields: + self.save() + + return updated_fields + + class WebhookSubscription(UUIDPKModel, ProjectRelatedModel): target_url = models.URLField(_("Target URL"), max_length=1024) sent = models.BooleanField(default=False) diff --git a/scanpipe/pipes/__init__.py b/scanpipe/pipes/__init__.py index 50f42d4a4..80953aa44 100644 --- a/scanpipe/pipes/__init__.py +++ b/scanpipe/pipes/__init__.py @@ -30,6 +30,7 @@ from django.db.models import Count from scanpipe.models import CodebaseResource +from scanpipe.models import DiscoveredDependency from scanpipe.models import DiscoveredPackage from scanpipe.pipes import scancode @@ -104,6 +105,37 @@ def update_or_create_package(project, package_data, codebase_resource=None): return package +def update_or_create_dependencies(project, dependency_data): + """ + Gets, updates or creates a DiscoveredDependency then returns it. + Uses the `project` and `dependency_data` mapping to lookup and creates the + DiscoveredDependency using its dependency_uid and for_package_uid as a unique key. + """ + for_package_uid = dependency_data.get("for_package_uid") + try: + dependency = project.discovereddependencys.get( + dependency_uid=dependency_data.get("dependency_uid"), + for_package_uid=for_package_uid, + ) + except DiscoveredDependency.DoesNotExist: + dependency = None + + if dependency: + dependency.update_from_data(dependency_data) + else: + dependency = DiscoveredDependency.create_from_data(project, dependency_data) + + if for_package_uid: + package_exists_in_project = project.discoveredpackages.filter( + package_uid=for_package_uid + ).exists() + if package_exists_in_project: + package = project.discoveredpackages.get(package_uid=for_package_uid) + dependency.discovered_packages.add(package) + + return dependency + + def analyze_scanned_files(project): """ Sets the status for CodebaseResource to unknown or no license. diff --git a/scanpipe/pipes/output.py b/scanpipe/pipes/output.py index 1dd04579b..fee4f771f 100644 --- a/scanpipe/pipes/output.py +++ b/scanpipe/pipes/output.py @@ -125,6 +125,7 @@ def __iter__(self): yield "{\n" yield from self.serialize(label="headers", generator=self.get_headers) yield from self.serialize(label="packages", generator=self.get_packages) + yield from self.serialize(label="dependencies", generator=self.get_dependencies) yield from self.serialize(label="files", generator=self.get_files, latest=True) yield "}" @@ -178,6 +179,16 @@ def get_packages(self, project): for obj in packages.iterator(): yield self.encode(DiscoveredPackageSerializer(obj).data) + def get_dependencies(self, project): + from scanpipe.api.serializers import DiscoveredDependencySerializer + + dependencies = project.discovereddependencys.all().order_by( + "purl", + ) + + for obj in dependencies.iterator(): + yield self.encode(DiscoveredDependencySerializer(obj).data) + def get_files(self, project): from scanpipe.api.serializers import CodebaseResourceSerializer diff --git a/scanpipe/pipes/scancode.py b/scanpipe/pipes/scancode.py index 34bf8ebdc..c3ec93158 100644 --- a/scanpipe/pipes/scancode.py +++ b/scanpipe/pipes/scancode.py @@ -36,6 +36,8 @@ from commoncode import fileutils from commoncode.resource import VirtualCodebase from extractcode import api as extractcode_api +from packagedcode import get_package_handler +from packagedcode import models as packagedcode_models from scancode import ScancodeError from scancode import Scanner from scancode import api as scancode_api @@ -136,7 +138,7 @@ def get_resource_info(location): file_info.update( { "type": resource_type, - "name": fileutils.file_base_name(location), + "name": fileutils.file_name(location), "extension": fileutils.file_extension(location), } ) @@ -230,10 +232,9 @@ def save_scan_package_results(codebase_resource, scan_results, scan_errors): Saves the resource scan package results in the database. Creates project errors if any occurred during the scan. """ - packages = scan_results.get("package_data", []) - if packages: - for package_data in packages: - codebase_resource.create_and_add_package(package_data) + package_data = scan_results.get("package_data", []) + if package_data: + codebase_resource.package_data = package_data codebase_resource.status = "application-package" codebase_resource.save() @@ -310,18 +311,84 @@ def scan_for_files(project): def scan_for_application_packages(project): """ - Runs a package scan on files without a status for a `project`. + Runs a package scan on files without a status for a `project`, + then create DiscoveredPackage and DiscoveredDependency instances + from the detected package data Multiprocessing is enabled by default on this pipe, the number of processes can be controlled through the SCANCODEIO_PROCESSES setting. """ resource_qs = project.codebaseresources.no_status() + + # Collect detected Package data and save it to the CodebaseResource it was + # detected from _scan_and_save( resource_qs=resource_qs, scan_func=scan_for_package_data, save_func=save_scan_package_results, ) + # Iterate through CodebaseResources with Package data and handle them using + # the proper Package handler from packagedcode + assemble_packages(project=project) + + +def add_to_package(package_uid, resource, project): + """ + Relate a DiscoveredPackage to `resource` from `project` using `package_uid` + """ + if not package_uid: + return + package_associated_with_resource = resource.discovered_packages.filter( + package_uid=package_uid + ).exists() + if not package_associated_with_resource: + package = project.discoveredpackages.get(package_uid=package_uid) + resource.discovered_packages.add(package) + + +def assemble_packages(project): + """ + Create instances of DiscoveredPackage and DiscoveredDependency for `project` + from the parsed package data present in the CodebaseResources of `project`. + """ + logger.info(f"Project: {project}:\n" "Function: assemble_packages\n") + seen_resource_paths = set() + for resource in project.codebaseresources.has_package_data(): + if resource.path in seen_resource_paths: + continue + + logger.info(f"Processing: CodebaseResource {resource.path}\n") + + for package_mapping in resource.package_data: + pd = packagedcode_models.PackageData.from_dict(mapping=package_mapping) + + logger.info(f"Processing: PackageData {pd.purl}\n") + + handler = get_package_handler(pd) + + logger.info(f"Selected: Package handler {handler}\n") + + items = handler.assemble( + package_data=pd, + resource=resource, + codebase=project, + package_adder=add_to_package, + ) + + for item in items: + logger.info(f"Processing: item {item}\n") + if isinstance(item, packagedcode_models.Package): + package_data = item.to_dict() + pipes.update_or_create_package(project, package_data) + elif isinstance(item, packagedcode_models.Dependency): + dependency_data = item.to_dict() + pipes.update_or_create_dependencies(project, dependency_data) + elif isinstance(item, CodebaseResource): + seen_resource_paths.add(item.path) + else: + logger.info(f"Unknown Package assembly item type: {item!r}\n") + def run_scancode(location, output_file, options, raise_on_error=False): """ @@ -411,6 +478,16 @@ def create_discovered_packages(project, scanned_codebase): pipes.update_or_create_package(project, package_data) +def create_discovered_dependencies(project, scanned_codebase): + """ + Saves the dependencies of a ScanCode `scanned_codebase` scancode.resource.Codebase + object to the database as a DiscoveredDependency of `project`. + """ + if hasattr(scanned_codebase.attributes, "dependencies"): + for dependency_data in scanned_codebase.attributes.dependencies: + pipes.update_or_create_dependencies(project, dependency_data) + + def set_codebase_resource_for_package(codebase_resource, discovered_package): """ Assigns the `discovered_package` to the `codebase_resource` and set its @@ -501,4 +578,5 @@ def create_inventory_from_scan(project, input_location): """ scanned_codebase = get_virtual_codebase(project, input_location) create_discovered_packages(project, scanned_codebase) + create_discovered_dependencies(project, scanned_codebase) create_codebase_resources(project, scanned_codebase) diff --git a/scanpipe/templates/scanpipe/dependency_detail.html b/scanpipe/templates/scanpipe/dependency_detail.html new file mode 100644 index 000000000..6bc6e0511 --- /dev/null +++ b/scanpipe/templates/scanpipe/dependency_detail.html @@ -0,0 +1,49 @@ +{% extends "scanpipe/base.html" %} +{% load static humanize %} + +{% block title %}ScanCode.io: {{ project.name }} - {{ object.name }}{% endblock %} + +{% block content %} +
{{ value|default_if_none:'' }}+
Package URL | +Extracted requirement | +scope | +Is runtime | +Is optional | +Is resolved | +Dependency UID | +For package UID | +Datafile path | +Datasource ID | +From Packages | +
---|---|---|---|---|---|---|---|---|---|---|
+ {{ dependency.purl }} + | ++ {{ dependency.extracted_requirement }} + | ++ {{ dependency.scope }} + | ++ {{ dependency.is_runtime }} + | ++ {{ dependency.is_optional }} + | ++ {{ dependency.is_resolved }} + | ++ {{ dependency.dependency_uid }} + | ++ {{ dependency.for_package_uid }} + | ++ {{ dependency.datafile_path }} + | ++ {{ dependency.datasource_id }} + | +
+
|
+
Dependencies
++ {% if project.dependency_count %} + + {{ project.dependency_count|intcomma }} + + {% else %} + + {{ project.dependency_count|intcomma }} + + {% endif %} +
+Resources
diff --git a/scanpipe/templates/scanpipe/project_detail.html b/scanpipe/templates/scanpipe/project_detail.html index ad1d47682..024603934 100644 --- a/scanpipe/templates/scanpipe/project_detail.html +++ b/scanpipe/templates/scanpipe/project_detail.html @@ -120,6 +120,28 @@