diff --git a/.travis.yml b/.travis.yml index c7745ecdd..9803df74a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,9 +11,14 @@ addons: before_install: - export DEBIAN_FRONTEND=noninteractive + - sudo add-apt-repository ppa:ubuntugis/ppa -y - sudo -E apt-get -yq update &>> ~/apt-get-update.log - sudo -E apt-get -yq --no-install-suggests --no-install-recommends --force-yes install postgresql-9.4-postgis-2.2 squid3 + - sudo apt-get -yq install libgdal1-dev + - gdal-config --version - export PATH=$(echo $PATH | tr ':' "\n" | sed '/\/opt\/python/d' | tr "\n" ":" | sed "s|::|:|g") + - export C_INCLUDE_PATH=/usr/include/gdal + - export CPLUS_INCLUDE_PATH=/usr/include/gdal install: - pip install tox==2.3.1 diff --git a/cadasta/organization/download/base.py b/cadasta/organization/download/base.py new file mode 100644 index 000000000..302676a15 --- /dev/null +++ b/cadasta/organization/download/base.py @@ -0,0 +1,49 @@ +from jsonattrs.models import Schema + + +class Exporter(): + def __init__(self, project): + self.project = project + self._schema_attrs = {} + + def get_schema_attrs(self, content_type): + content_type_key = '{}.{}'.format(content_type.app_label, + content_type.model) + + if content_type_key not in self._schema_attrs.keys(): + selectors = [ + self.project.organization.id, + self.project.id, + self.project.current_questionnaire + ] + schemas = Schema.objects.lookup( + content_type=content_type, selectors=selectors + ) + + attrs = [] + if schemas: + attrs = [ + a for s in schemas + for a in s.attributes.all() if not a.omit + ] + self._schema_attrs[content_type_key] = attrs + + return self._schema_attrs[content_type_key] + + def get_values(self, item, model_attrs, schema_attrs): + values = [] + for attr in model_attrs: + if '.' in attr: + attr_items = attr.split('.') + value = None + for a in attr_items: + value = (getattr(item, a) + if not value else getattr(value, a)) + values.append(value) + else: + values.append(getattr(item, attr)) + + for attr in schema_attrs: + values.append(item.attributes.get(attr.name, '')) + + return values diff --git a/cadasta/organization/download/shape.py b/cadasta/organization/download/shape.py new file mode 100644 index 000000000..7ba4b7163 --- /dev/null +++ b/cadasta/organization/download/shape.py @@ -0,0 +1,115 @@ +import os +import csv +from osgeo import ogr, osr +from zipfile import ZipFile +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.template.loader import render_to_string + +from .base import Exporter + +MIME_TYPE = 'application/zip' + + +class ShapeExporter(Exporter): + def write_items(self, filename, queryset, content_type, model_attrs): + schema_attrs = self.get_schema_attrs(content_type) + fields = list(model_attrs) + [a.name for a in schema_attrs] + + with open(filename, 'w+', newline='') as csvfile: + csvwriter = csv.writer(csvfile) + csvwriter.writerow(fields) + + for item in queryset: + values = self.get_values(item, model_attrs, schema_attrs) + csvwriter.writerow(values) + + def write_relationships(self, filename): + content_type = ContentType.objects.get(app_label='party', + model='tenurerelationship') + self.write_items(filename, + self.project.tenure_relationships.all(), + content_type, + ('id', 'party_id', 'spatial_unit_id', + 'tenure_type.label')) + + def write_parties(self, filename): + content_type = ContentType.objects.get(app_label='party', + model='party') + self.write_items(filename, + self.project.parties.all(), + content_type, + ('id', 'name', 'type')) + + def write_features(self, layers, filename): + content_type = ContentType.objects.get(app_label='spatial', + model='spatialunit') + model_attrs = ('id', 'type') + schema_attrs = self.get_schema_attrs(content_type) + + with open(filename, 'w+', newline='') as csvfile: + csvwriter = csv.writer(csvfile) + csvwriter.writerow(list(model_attrs) + + [a.name for a in schema_attrs]) + + for su in self.project.spatial_units.all(): + geom = ogr.CreateGeometryFromWkt(su.geometry.wkt) + layer_type = geom.GetGeometryType() - 1 + layer = layers[layer_type] + + feature = ogr.Feature(layer.GetLayerDefn()) + feature.SetGeometry(ogr.CreateGeometryFromWkt(su.geometry.wkt)) + feature.SetField('id', su.id) + layer.CreateFeature(feature) + feature.Destroy() + + values = self.get_values(su, model_attrs, schema_attrs) + csvwriter.writerow(values) + + def create_datasource(self, dst_dir, f_name): + if not os.path.exists(dst_dir): + os.makedirs(dst_dir) + path = os.path.join(dst_dir, f_name + '-point.shp') + driver = ogr.GetDriverByName('ESRI Shapefile') + return driver.CreateDataSource(path) + + def create_shp_layers(self, datasource, f_name): + srs = osr.SpatialReference() + srs.ImportFromEPSG(4326) + + layers = ( + datasource.CreateLayer(f_name + '-point', srs, geom_type=1), + datasource.CreateLayer(f_name + '-line', srs, geom_type=2), + datasource.CreateLayer(f_name + '-polygon', srs, geom_type=3) + ) + + for layer in layers: + field = ogr.FieldDefn('id', ogr.OFTString) + layer.CreateField(field) + + return layers + + def make_download(self, f_name): + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/{}'.format(f_name)) + + ds = self.create_datasource(dst_dir, self.project.slug) + layers = self.create_shp_layers(ds, self.project.slug) + + self.write_features(layers, os.path.join(dst_dir, 'locations.csv')) + self.write_relationships(os.path.join(dst_dir, 'relationships.csv')) + self.write_parties(os.path.join(dst_dir, 'parties.csv')) + + ds.Destroy() + + path = os.path.join(settings.MEDIA_ROOT, 'temp/{}.zip'.format(f_name)) + readme = render_to_string( + 'organization/download/shp_readme.txt', + {'project_name': self.project.name, + 'project_slug': self.project.slug} + ) + with ZipFile(path, 'a') as myzip: + myzip.writestr('README.txt', readme) + for file in os.listdir(dst_dir): + myzip.write(os.path.join(dst_dir, file), arcname=file) + + return path, MIME_TYPE diff --git a/cadasta/organization/download/xls.py b/cadasta/organization/download/xls.py index 1fc3714e2..dfb706162 100644 --- a/cadasta/organization/download/xls.py +++ b/cadasta/organization/download/xls.py @@ -2,50 +2,23 @@ from openpyxl import Workbook from django.conf import settings from django.contrib.contenttypes.models import ContentType -from jsonattrs.models import Schema +from .base import Exporter -MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' +MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' -class XLSExporter(): - def __init__(self, project): - self.project = project +class XLSExporter(Exporter): def write_items(self, worksheet, queryset, content_type, model_attrs): - selectors = [ - self.project.organization.id, - self.project.id, - self.project.current_questionnaire - ] - schemas = Schema.objects.lookup( - content_type=content_type, selectors=selectors - ) - attrs = [] - if schemas: - attrs = [a for s in schemas - for a in s.attributes.all() if not a.omit] + schema_attrs = self.get_schema_attrs(content_type) # write column labels - worksheet.append(model_attrs + [a.name for a in attrs]) + worksheet.append(model_attrs + [a.name for a in schema_attrs]) # write data for i, item in enumerate(queryset): - values = [] - for j, attr in enumerate(model_attrs): - if '.' in attr: - attr_items = attr.split('.') - value = None - for a in attr_items: - value = (getattr(item, a) - if not value else getattr(value, a)) - values.append(value) - else: - values.append(getattr(item, attr)) - - for j, attr in enumerate(attrs): - values.append(item.attributes.get(attr.name, '')) - + values = self.get_values(item, model_attrs, schema_attrs) worksheet.append(values) def write_locations(self): @@ -61,7 +34,8 @@ def write_parties(self): parties = self.project.parties.all() content_type = ContentType.objects.get(app_label='party', model='party') - self.write_items(worksheet, parties, content_type, ['id', 'name']) + self.write_items(worksheet, parties, content_type, + ['id', 'name', 'type']) def write_relationships(self): worksheet = self.workbook.create_sheet(title='relationships') diff --git a/cadasta/organization/forms.py b/cadasta/organization/forms.py index e323db495..87361a80d 100644 --- a/cadasta/organization/forms.py +++ b/cadasta/organization/forms.py @@ -20,6 +20,7 @@ from .fields import ProjectRoleField, PublicPrivateField, ContactsField from .download.xls import XLSExporter from .download.resources import ResourceExporter +from .download.shape import ShapeExporter FORM_CHOICES = ROLE_CHOICES + (('Pb', _('Public User')),) QUESTIONNAIRE_TYPES = [ @@ -375,7 +376,8 @@ def save(self): class DownloadForm(forms.Form): - CHOICES = (('all', 'All data'), ('xls', 'XLS'), ('res', 'Resources')) + CHOICES = (('all', 'All data'), ('xls', 'XLS'), ('shp', 'SHP'), + ('res', 'Resources')) type = forms.ChoiceField(choices=CHOICES, initial='xls') def __init__(self, project, user, *args, **kwargs): @@ -389,7 +391,10 @@ def get_file(self): file_name = '{}-{}-{}'.format(self.project.id, self.user.id, t) type = self.cleaned_data['type'] - if type == 'xls': + if type == 'shp': + e = ShapeExporter(self.project) + path, mime = e.make_download(file_name + '-shp') + elif type == 'xls': e = XLSExporter(self.project) path, mime = e.make_download(file_name + '-xls') elif type == 'res': @@ -398,11 +403,14 @@ def get_file(self): elif type == 'all': res_exporter = ResourceExporter(self.project) xls_exporter = XLSExporter(self.project) + shp_exporter = ShapeExporter(self.project) path, mime = res_exporter.make_download(file_name + '-res') data_path, _ = xls_exporter.make_download(file_name + '-xls') + shp_path, _ = shp_exporter.make_download(file_name + '-shp') with ZipFile(path, 'a') as myzip: myzip.write(data_path, arcname='data.xlsx') + myzip.write(shp_path, arcname='data-shp.zip') myzip.close() return path, mime diff --git a/cadasta/organization/tests/test_downloads.py b/cadasta/organization/tests/test_downloads.py index f81c8c12f..52ca05286 100644 --- a/cadasta/organization/tests/test_downloads.py +++ b/cadasta/organization/tests/test_downloads.py @@ -1,8 +1,10 @@ import pytest import time import os +import csv from openpyxl import load_workbook, Workbook from zipfile import ZipFile + from django.conf import settings from django.contrib.contenttypes.models import ContentType @@ -17,9 +19,309 @@ from resources.models import ContentObject from core.tests.base_test_case import UserTestCase from party.tests.factories import TenureRelationshipFactory, PartyFactory +from party.models import TenureRelationshipType +from ..download.base import Exporter from ..download.xls import XLSExporter from ..download.resources import ResourceExporter +from ..download.shape import ShapeExporter + + +class BaseExporterTest(UserTestCase): + def test_init(self): + project = ProjectFactory.build() + exporter = Exporter(project) + assert exporter.project == project + + def test_get_schema_attrs_empty(self): + project = ProjectFactory.create() + content_type = ContentType.objects.get(app_label='spatial', + model='spatialunit') + exporter = Exporter(project) + assert exporter.get_schema_attrs(content_type) == [] + assert exporter._schema_attrs['spatial.spatialunit'] == [] + + def test_get_schema_attrs(self): + project = ProjectFactory.create(current_questionnaire='123abc') + content_type = ContentType.objects.get(app_label='spatial', + model='spatialunit') + schema = Schema.objects.create( + content_type=content_type, + selectors=(project.organization.id, project.id, '123abc', )) + text_type = AttributeType.objects.get(name='text') + Attribute.objects.create( + schema=schema, + name='key', long_name='Test field', + attr_type=text_type, index=0, + required=False, omit=False + ) + Attribute.objects.create( + schema=schema, + name='key_2', long_name='Test field', + attr_type=text_type, index=1, + required=False, omit=False + ) + Attribute.objects.create( + schema=schema, + name='key_3', long_name='Test field', + attr_type=text_type, index=2, + required=False, omit=True + ) + + exporter = Exporter(project) + attrs = exporter.get_schema_attrs(content_type) + assert len(attrs) == 2 + + def test_get_values(self): + project = ProjectFactory.create(current_questionnaire='123abc') + exporter = Exporter(project) + content_type = ContentType.objects.get(app_label='party', + model='tenurerelationship') + schema = Schema.objects.create( + content_type=content_type, + selectors=(project.organization.id, project.id, '123abc', )) + text_type = AttributeType.objects.get(name='text') + attr = Attribute.objects.create( + schema=schema, + name='key', long_name='Test field', + attr_type=text_type, index=0, + required=False, omit=False + ) + + ttype = TenureRelationshipType.objects.get(id='LH') + item = TenureRelationshipFactory.create(project=project, + tenure_type=ttype, + attributes={'key': 'text'}) + model_attrs = ('id', 'party_id', 'spatial_unit_id', + 'tenure_type.label') + schema_attrs = [attr] + values = exporter.get_values(item, model_attrs, schema_attrs) + assert values == [item.id, item.party_id, item.spatial_unit_id, + 'Leasehold', 'text'] + + +@pytest.mark.usefixtures('clear_temp') +class ShapeTest(UserTestCase): + def test_init(self): + project = ProjectFactory.build() + exporter = ShapeExporter(project) + assert exporter.project == project + + def test_create_datasource(self): + ensure_dirs() + project = ProjectFactory.create() + exporter = ShapeExporter(project) + + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/file') + ds = exporter.create_datasource(dst_dir, 'file0') + assert (ds.GetName() == + os.path.join(settings.MEDIA_ROOT, 'temp/file/file0-point.shp')) + ds.Destroy() + + def test_create_shp_layers(self): + ensure_dirs() + project = ProjectFactory.create() + exporter = ShapeExporter(project) + + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/file6') + ds = exporter.create_datasource(dst_dir, 'file6') + layers = exporter.create_shp_layers(ds, 'file6') + assert len(layers) == 3 + assert layers[0].GetName() == 'file6-point' + assert layers[1].GetName() == 'file6-line' + assert layers[2].GetName() == 'file6-polygon' + ds.Destroy() + + def test_write_items(self): + project = ProjectFactory.create(current_questionnaire='123abc') + + content_type = ContentType.objects.get(app_label='party', + model='party') + schema = Schema.objects.create( + content_type=content_type, + selectors=(project.organization.id, project.id, '123abc', )) + + for idx, type in enumerate(['text', 'boolean', 'dateTime', 'integer']): + attr_type = AttributeType.objects.get(name=type) + Attribute.objects.create( + schema=schema, + name=type, long_name=type, + attr_type=attr_type, index=idx, + required=False, omit=False + ) + + party = PartyFactory.create( + project=project, + name='Donald Duck', + type='IN', + attributes={ + 'text': 'text', + 'boolean': True, + 'dateTime': '2011-08-12 11:13', + 'integer': 1, + } + ) + + exporter = ShapeExporter(project) + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/party') + if not os.path.exists(dst_dir): + os.makedirs(dst_dir) + filename = os.path.join(dst_dir, 'parties.csv') + exporter.write_items(filename, + [party], + content_type, + ('id', 'name', 'type')) + + with open(filename) as csvfile: + csvreader = csv.reader(csvfile) + + for i, row in enumerate(csvreader): + assert len(row) == 7 + if i == 0: + assert row == ['id', 'name', 'type', 'text', 'boolean', + 'dateTime', 'integer'] + else: + assert row == [party.id, party.name, party.type, 'text', + 'True', '2011-08-12 11:13', '1'] + + def test_write_features(self): + ensure_dirs() + project = ProjectFactory.create(current_questionnaire='123abc') + exporter = ShapeExporter(project) + + content_type = ContentType.objects.get(app_label='spatial', + model='spatialunit') + schema = Schema.objects.create( + content_type=content_type, + selectors=(project.organization.id, project.id, '123abc', )) + attr_type = AttributeType.objects.get(name='text') + Attribute.objects.create( + schema=schema, + name='key', long_name='Test field', + attr_type=attr_type, index=0, + required=False, omit=False + ) + + su1 = SpatialUnitFactory.create( + project=project, + geometry='POINT (1 1)', + attributes={'key': 'value 1'}) + su2 = SpatialUnitFactory.create( + project=project, + geometry='POINT (2 2)', + attributes={'key': 'value 2'}) + + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/file4') + ds = exporter.create_datasource(dst_dir, 'file4') + layers = exporter.create_shp_layers(ds, 'file4') + + csvfile = os.path.join(dst_dir, 'locations.csv') + exporter.write_features(layers, csvfile) + + assert len(layers[0]) == 2 + f = layers[0].GetNextFeature() + while f: + geom = f.geometry() + assert geom.ExportToWkt() in ['POINT (1 1)', 'POINT (2 2)'] + assert f.GetFieldAsString('id') in [su1.id, su2.id] + + f = layers[0].GetNextFeature() + + ds.Destroy() + + with open(csvfile) as csvfile: + csvreader = csv.reader(csvfile) + for i, row in enumerate(csvreader): + if i == 0: + assert row == ['id', 'type', 'key'] + elif row[0] == su1.id: + assert row == [su1.id, su1.type, 'value 1'] + elif row[1] == su2.id: + assert row == [su2.id, su2.type, 'value 2'] + + def test_write_relationships(self): + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/rels') + if not os.path.exists(dst_dir): + os.makedirs(dst_dir) + filename = os.path.join(dst_dir, 'releationships.csv') + + project = ProjectFactory.create() + exporter = ShapeExporter(project) + exporter.write_relationships(filename) + + with open(filename) as csvfile: + csvreader = csv.reader(csvfile) + for i, row in enumerate(csvreader): + if i == 0: + assert row == ['id', 'party_id', 'spatial_unit_id', + 'tenure_type.label'] + else: + assert False, "Too many rows in CSV." + + def test_write_parties(self): + dst_dir = os.path.join(settings.MEDIA_ROOT, 'temp/party') + if not os.path.exists(dst_dir): + os.makedirs(dst_dir) + filename = os.path.join(dst_dir, 'parties.csv') + + project = ProjectFactory.create() + exporter = ShapeExporter(project) + exporter.write_parties(filename) + + with open(filename) as csvfile: + csvreader = csv.reader(csvfile) + for i, row in enumerate(csvreader): + if i == 0: + assert row == ['id', 'name', 'type'] + else: + assert False, "Too many rows in CSV." + + def test_make_download(self): + ensure_dirs() + project = ProjectFactory.create(current_questionnaire='123abc') + exporter = ShapeExporter(project) + + content_type = ContentType.objects.get(app_label='spatial', + model='spatialunit') + schema = Schema.objects.create( + content_type=content_type, + selectors=(project.organization.id, project.id, '123abc', )) + attr_type = AttributeType.objects.get(name='text') + Attribute.objects.create( + schema=schema, + name='key', long_name='Test field', + attr_type=attr_type, index=0, + required=False, omit=False + ) + + SpatialUnitFactory.create( + project=project, + geometry='POINT (1 1)', + attributes={'key': 'value 1'}) + + path, mime = exporter.make_download('file5') + + assert path == os.path.join(settings.MEDIA_ROOT, 'temp/file5.zip') + assert mime == 'application/zip' + + with ZipFile(path, 'r') as testzip: + assert len(testzip.namelist()) == 16 + assert project.slug + '-point.dbf' in testzip.namelist() + assert project.slug + '-point.prj' in testzip.namelist() + assert project.slug + '-point.shp' in testzip.namelist() + assert project.slug + '-point.shx' in testzip.namelist() + assert project.slug + '-line.dbf' in testzip.namelist() + assert project.slug + '-line.prj' in testzip.namelist() + assert project.slug + '-line.shp' in testzip.namelist() + assert project.slug + '-line.shx' in testzip.namelist() + assert project.slug + '-polygon.dbf' in testzip.namelist() + assert project.slug + '-polygon.prj' in testzip.namelist() + assert project.slug + '-polygon.shp' in testzip.namelist() + assert project.slug + '-polygon.shx' in testzip.namelist() + assert 'relationships.csv' in testzip.namelist() + assert 'parties.csv' in testzip.namelist() + assert 'locations.csv' in testzip.namelist() + assert 'README.txt' in testzip.namelist() @pytest.mark.usefixtures('clear_temp') @@ -130,7 +432,6 @@ def test_make_resource_worksheet(self): project = ProjectFactory.create() exporter = ResourceExporter(project) - # path = os.path.join(settings.MEDIA_ROOT, 'temp/test-res.xlsx') data = [ ['1', 'n1', 'd1', 'f1', 'l1', 'p1', 'r1'], ['2', 'n2', 'd2', 'f2', 'l2', 'p2', 'r2'] diff --git a/cadasta/organization/tests/test_forms.py b/cadasta/organization/tests/test_forms.py index 33e45e2fd..bc881889e 100644 --- a/cadasta/organization/tests/test_forms.py +++ b/cadasta/organization/tests/test_forms.py @@ -725,6 +725,36 @@ def test_init(self): assert form.project == project assert form.user == user + def test_get_shape_download(self): + ensure_dirs() + data = {'type': 'shp'} + user = UserFactory.create() + project = ProjectFactory.create() + form = forms.DownloadForm(project, user, data=data) + assert form.is_valid() is True + path, mime = form.get_file() + assert '{}-{}'.format(project.id, user.id) in path + assert (mime == 'application/zip') + + with ZipFile(path, 'r') as testzip: + assert len(testzip.namelist()) == 16 + assert project.slug + '-point.dbf' in testzip.namelist() + assert project.slug + '-point.prj' in testzip.namelist() + assert project.slug + '-point.shp' in testzip.namelist() + assert project.slug + '-point.shx' in testzip.namelist() + assert project.slug + '-line.dbf' in testzip.namelist() + assert project.slug + '-line.prj' in testzip.namelist() + assert project.slug + '-line.shp' in testzip.namelist() + assert project.slug + '-line.shx' in testzip.namelist() + assert project.slug + '-polygon.dbf' in testzip.namelist() + assert project.slug + '-polygon.prj' in testzip.namelist() + assert project.slug + '-polygon.shp' in testzip.namelist() + assert project.slug + '-polygon.shx' in testzip.namelist() + assert 'relationships.csv' in testzip.namelist() + assert 'parties.csv' in testzip.namelist() + assert 'locations.csv' in testzip.namelist() + assert 'README.txt' in testzip.namelist() + def test_get_xls_download(self): ensure_dirs() data = {'type': 'xls'} @@ -762,7 +792,8 @@ def test_get_all_download(self): assert mime == 'application/zip' with ZipFile(path, 'r') as testzip: - assert len(testzip.namelist()) == 3 + assert len(testzip.namelist()) == 4 assert res.original_file in testzip.namelist() assert 'resources.xlsx' in testzip.namelist() assert 'data.xlsx' in testzip.namelist() + assert 'data-shp.zip' in testzip.namelist() diff --git a/cadasta/templates/organization/download/shp_readme.txt b/cadasta/templates/organization/download/shp_readme.txt new file mode 100644 index 000000000..084448bc2 --- /dev/null +++ b/cadasta/templates/organization/download/shp_readme.txt @@ -0,0 +1,7 @@ +You have downloaded all data from project "{{ project_name }}" in shapefile format. + +Besides this README, the ZIP archive contains the shape files and CSV files containing the project data. + +Shape files can only store geometries of a single type: either point, line or polygon. Because of this, you will find three shape files ({{ project_slug }}-point.shp, {{ project_slug }}-line.shp, and {{ project_slug }}-polygon.shp) containing the geometries of all locations in project "{{ project_name }}", along with the corresponding *.shx, *.prj and *.dbf files. The attribute table of each shapefile contains only the location ID. + +The attributes for locations, parties and relationships are provided in CSV files called locations.csv, parties.csv and relationships.csv. We use CSV instead of dBase files to avoid certain restrictions of the dBase format that would cause some data to be truncated. diff --git a/cadasta/templates/organization/project_download.html b/cadasta/templates/organization/project_download.html index cb178eb78..d43d852ad 100644 --- a/cadasta/templates/organization/project_download.html +++ b/cadasta/templates/organization/project_download.html @@ -12,13 +12,20 @@