diff --git a/src/apps/api/serializers/datasets.py b/src/apps/api/serializers/datasets.py index 110aba43f..f3f9eee76 100644 --- a/src/apps/api/serializers/datasets.py +++ b/src/apps/api/serializers/datasets.py @@ -62,6 +62,28 @@ def create(self, validated_data): return instance +class DatasetSerializer(serializers.ModelSerializer): + created_by = serializers.SerializerMethodField() + + class Meta: + model = Data + fields = ( + 'id', + 'type', + 'name', + 'description', + 'file_size', + 'license', + 'downloads', + 'is_verified', + 'created_when', + 'created_by', + ) + + def get_created_by(self, obj): + return obj.created_by.username + + class DataSimpleSerializer(serializers.ModelSerializer): class Meta: diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index fb50b5f76..5b52b4b95 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -1,9 +1,11 @@ from django.urls import reverse from faker import Factory +from django.test import TestCase from rest_framework.test import APITestCase from datasets.models import Data from factories import UserFactory, DataFactory from utils.data import pretty_bytes, gb_to_bytes +from unittest.mock import patch faker = Factory.create() @@ -86,3 +88,221 @@ def test_dataset_api_check_quota(self): 'file_size': file_size, }) assert resp.status_code == 201 + + +class DatasetDetailTests(TestCase): + def setUp(self): + self.owner = UserFactory(username="owner") + self.other_user = UserFactory(username="other") + self.client.force_login(self.owner) + + # Create datasets + self.public_dataset = DataFactory( + name="Public Dataset", + is_public=True, + created_by=self.owner, + type=Data.PUBLIC_DATA + ) + + self.private_dataset = DataFactory( + name="Private Dataset", + is_public=False, + created_by=self.owner, + type=Data.INPUT_DATA + ) + + self.other_private_dataset = DataFactory( + name="Other User's Private Dataset", + is_public=False, + created_by=self.other_user, + type=Data.REFERENCE_DATA + ) + + def test_view_public_dataset(self): + # Public dataset should be accessible by anyone + self.client.logout() + response = self.client.get(reverse("datasets:detail", args=[self.public_dataset.pk])) + self.assertEqual(response.status_code, 200) + + def test_view_private_dataset_as_owner(self): + # Owner should be able to access their own private dataset + response = self.client.get(reverse("datasets:detail", args=[self.private_dataset.pk])) + self.assertEqual(response.status_code, 200) + + def test_view_private_dataset_as_other_user(self): + # Another user should not be able to access a private dataset + self.client.force_login(self.other_user) + response = self.client.get(reverse("datasets:detail", args=[self.private_dataset.pk])) + self.assertEqual(response.status_code, 404) + + def test_view_nonexistent_dataset(self): + # Accessing a non-existent dataset should return 404 + response = self.client.get(reverse("datasets:detail", args=[99999])) + self.assertEqual(response.status_code, 404) + + +class DatasetDownloadTests(TestCase): + def setUp(self): + self.owner = UserFactory(username="owner") + self.other_user = UserFactory(username="other") + self.client.force_login(self.owner) + + self.public_dataset = DataFactory( + is_public=True, + created_by=self.owner, + downloads=5 + ) + + self.private_dataset = DataFactory( + is_public=False, + created_by=self.owner, + downloads=2 + ) + + @patch("datasets.views.make_url_sassy") # Replaces the real `make_url_sassy` function in this test only + def test_download_public_dataset(self, mock_make_url_sassy): + # Mock the URL that would normally be generated for the file + # This avoids depending on actual file storage or signature logic + mock_make_url_sassy.return_value = "http://codebench-storage/public_dataset.zip" + + response = self.client.get(reverse("datasets:download_by_pk", args=[self.public_dataset.pk])) + + # Should redirect to the URL + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "http://codebench-storage/public_dataset.zip") + + # Should increment download count + self.public_dataset.refresh_from_db() + self.assertEqual(self.public_dataset.downloads, 6) + + @patch("datasets.views.make_url_sassy") # Replaces the real `make_url_sassy` function in this test only + def test_download_private_dataset_as_owner(self, mock_make_url_sassy): + # Mock the URL that would normally be generated for the file + # This avoids depending on actual file storage or signature logic + mock_make_url_sassy.return_value = "http://codebench-storage/private_dataset.zip" + + response = self.client.get(reverse("datasets:download_by_pk", args=[self.private_dataset.pk])) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response["Location"], "http://codebench-storage/private_dataset.zip") + + self.private_dataset.refresh_from_db() + self.assertEqual(self.private_dataset.downloads, 3) + + def test_download_private_dataset_as_other_user(self): + # Authenticate as a different user who is not the owner + self.client.force_login(self.other_user) + + response = self.client.get(reverse("datasets:download_by_pk", args=[self.private_dataset.pk])) + + # Should return 404 (access denied) + self.assertEqual(response.status_code, 404) + + def test_download_nonexistent_dataset(self): + response = self.client.get(reverse("datasets:download_by_pk", args=[99999])) + + # Should return 404 (access denied) + self.assertEqual(response.status_code, 404) + + +class DatasetCreateTests(APITestCase): + def setUp(self): + self.user = UserFactory(username='creator', password='creator') + self.client.login(username='creator', password='creator') + + @patch("api.views.datasets.make_url_sassy") # Replaces the real `make_url_sassy` function in this test only + def test_create_dataset_success(self, mock_make_url_sassy): + fake_sassy_url = "https://codabench-storage/dataset.zip" + mock_make_url_sassy.return_value = fake_sassy_url + + # Case 1: Without is_public (should default to False) + resp = self.client.post(reverse("data-list"), { + 'name': 'my-new-dataset', + 'type': Data.PUBLIC_DATA, + 'request_sassy_file_name': faker.file_name(extension='.zip'), + 'file_size': 1234, + 'file_name': faker.file_name(), + }) + self.assertEqual(resp.status_code, 201) + self.assertIn("key", resp.data) + self.assertEqual(resp.data["sassy_url"], fake_sassy_url) + + dataset = Data.objects.get(name="my-new-dataset") + self.assertEqual(dataset.created_by, self.user) + self.assertFalse(dataset.is_public) + mock_make_url_sassy.assert_called_once_with(dataset.data_file.name, 'w') + + # Case 2: With is_public=True + mock_make_url_sassy.reset_mock() + resp = self.client.post(reverse("data-list"), { + 'name': 'my-public-dataset', + 'type': Data.PUBLIC_DATA, + 'request_sassy_file_name': faker.file_name(extension='.zip'), + 'file_size': 1234, + 'file_name': faker.file_name(), + 'is_public': True + }) + self.assertEqual(resp.status_code, 201) + dataset = Data.objects.get(name="my-public-dataset") + self.assertTrue(dataset.is_public) + mock_make_url_sassy.assert_called_once_with(dataset.data_file.name, 'w') + + def test_cannot_create_dataset_with_missing_fields(self): + + # missing file_size + resp = self.client.post(reverse("data-list"), { + 'name': 'incomplete-dataset', + 'file_name': faker.file_name(), + 'type': Data.PUBLIC_DATA, + 'request_sassy_file_name': faker.file_name(extension='.zip'), + + }) + self.assertEqual(resp.status_code, 400) + self.assertIn("file_size", resp.data) + self.assertEqual(resp.data["file_size"], "This field is required.") + + # missing request_sassy_file_name + resp = self.client.post(reverse("data-list"), { + 'name': 'incomplete-dataset', + 'file_name': faker.file_name(), + 'type': Data.PUBLIC_DATA, + 'file_size': 1234, + }) + self.assertEqual(resp.status_code, 400) + self.assertIn("request_sassy_file_name", resp.data) + self.assertEqual(resp.data["request_sassy_file_name"][0], "This field is required.") + + # missing type + resp = self.client.post(reverse("data-list"), { + 'name': 'incomplete-dataset', + 'file_name': faker.file_name(), + 'file_size': 1234, + 'request_sassy_file_name': faker.file_name(extension='.zip'), + }) + self.assertEqual(resp.status_code, 400) + self.assertIn("type", resp.data) + self.assertEqual(resp.data["type"][0], "This field is required.") + + def test_cannot_create_dataset_with_invalid_file_size(self): + resp = self.client.post(reverse("data-list"), { + 'name': 'invalid-size-dataset', + 'file_name': faker.file_name(), + 'type': Data.PUBLIC_DATA, + 'request_sassy_file_name': faker.file_name(), + 'file_size': "not-a-number", # invalid type + }) + + self.assertEqual(resp.status_code, 400) + self.assertIn("file_size", resp.data) + self.assertEqual(resp.data["file_size"][0], "A valid number is required.") + + def test_cannot_create_dataset_unauthenticated(self): + self.client.logout() + resp = self.client.post(reverse("data-list"), { + 'name': 'unauth-dataset', + 'file_name': faker.file_name(), + 'type': Data.PUBLIC_DATA, + 'request_sassy_file_name': faker.file_name(), + 'file_size': 1234, + }) + self.assertEqual(resp.status_code, 403) diff --git a/src/apps/api/tests/test_public_datasets.py b/src/apps/api/tests/test_public_datasets.py new file mode 100644 index 000000000..4ae1c515c --- /dev/null +++ b/src/apps/api/tests/test_public_datasets.py @@ -0,0 +1,106 @@ +from rest_framework.test import APIClient +from django.test import TestCase +from factories import UserFactory, DataFactory +from datasets.models import Data + + +class PublicDatasetsTests(TestCase): + def setUp(self): + # Set up test client and authenticate as a test user + self.client = APIClient() + self.user = UserFactory() + self.client.force_authenticate(user=self.user) + + # Create public datasets with varying metadata to test filters and sorting + self.dataset1 = DataFactory( + name="Climate Data", + description="Temperature and rainfall records", + is_public=True, + type=Data.PUBLIC_DATA, + license="MIT", + is_verified=True, + downloads=10 + ) + + self.dataset2 = DataFactory( + name="Vision Dataset", + description="Images for computer vision", + is_public=True, + type=Data.PUBLIC_DATA, + license=None, + is_verified=False, + downloads=25 + ) + + self.dataset3 = DataFactory( + name="Unverified Text", + description="NLP dataset", + is_public=True, + type=Data.PUBLIC_DATA, + license="Apache 2.0", + is_verified=False, + downloads=5 + ) + + self.dataset4 = DataFactory( + name="Recent Genomics", + description="DNA sequences", + is_public=True, + type=Data.PUBLIC_DATA, + downloads=40, + is_verified=True + ) + + def test_default_ordering_recently_added(self): + # Test default ordering by ID in descending order (most recently created datasets first) + response = self.client.get("/api/datasets/public/") + self.assertEqual(response.status_code, 200) + ids = [d["id"] for d in response.data["results"]] + self.assertEqual(ids, sorted(ids, reverse=True)) # Default ordering by -id + + def test_ordering_by_most_downloaded(self): + # Test ordering datasets by download count in descending order + response = self.client.get("/api/datasets/public/?ordering=most_downloaded") + self.assertEqual(response.status_code, 200) + downloads = [d["downloads"] for d in response.data["results"]] + self.assertEqual(downloads, sorted(downloads, reverse=True)) + + def test_filter_by_search_term(self): + # Test full-text search in dataset name and description + response = self.client.get("/api/datasets/public/?search=vision") + self.assertEqual(response.status_code, 200) + names = [d["name"].lower() + d["description"].lower() for d in response.data["results"]] + self.assertTrue(all("vision" in text for text in names)) + + def test_filter_by_has_license_true(self): + # Test filtering datasets that have a license field set + response = self.client.get("/api/datasets/public/?has_license=true") + self.assertEqual(response.status_code, 200) + results = response.data["results"] + self.assertTrue(all(d["license"] is not None for d in results)) + + def test_filter_by_is_verified_true(self): + # Test filtering datasets that are verified (is_verified=True) + response = self.client.get("/api/datasets/public/?is_verified=true") + self.assertEqual(response.status_code, 200) + results = response.data["results"] + self.assertTrue(all(d["is_verified"] is True for d in results)) + + def test_combined_filter_verified_with_license(self): + # Test filtering datasets that are both verified and have a license + response = self.client.get("/api/datasets/public/?is_verified=true&has_license=true") + self.assertEqual(response.status_code, 200) + results = response.data["results"] + for d in results: + self.assertTrue(d["is_verified"]) + self.assertIsNotNone(d["license"]) + + def test_combined_search_and_filter(self): + # Test applying both search and is_verified filters together + response = self.client.get("/api/datasets/public/?search=genomics&is_verified=true") + self.assertEqual(response.status_code, 200) + results = response.data["results"] + + # Expect exactly one match with the correct name + self.assertEqual(len(results), 1) + self.assertEqual(results[0]["name"], "Recent Genomics") diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index 8618af38e..6ceec39eb 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -9,8 +9,9 @@ from rest_framework.filters import SearchFilter from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet +from rest_framework.permissions import AllowAny -from api.pagination import BasicPagination +from api.pagination import BasicPagination, LargePagination from api.serializers import datasets as serializers from datasets.models import Data, DataGroup from competitions.models import CompetitionCreationTaskStatus @@ -88,19 +89,38 @@ def get_queryset(self): return qs def get_serializer_class(self): - if self.request.method == 'GET': + if self.action == 'public': + return serializers.DatasetSerializer + elif self.request.method == 'GET': return serializers.DataDetailSerializer else: return serializers.DataSerializer + def get_permissions(self): + if self.action == 'public': + return [AllowAny()] + return super().get_permissions() + def create(self, request, *args, **kwargs): + # Check required field + if not request.data.get("file_size"): + return Response({"file_size": "This field is required."}, status=status.HTTP_400_BAD_REQUEST) + # Check file_size is float + try: + file_size = float(request.data.get('file_size', 0)) + except (TypeError, ValueError): + return Response( + {"file_size": ["A valid number is required."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Check User quota storage_used = float(request.user.get_used_storage_space()) quota = float(request.user.quota) quota = gb_to_bytes(quota) - file_size = float(request.data['file_size']) if storage_used + file_size > quota: - available_space = pretty_bytes(quota - storage_used) + available_space = quota - storage_used + available_space = pretty_bytes(available_space, return_0_for_invalid=True) file_size = pretty_bytes(file_size) message = f'Insufficient space. Your available space is {available_space}. The file size is {file_size}. Please free up some space and try again. You can manage your files in the Resources page.' return Response({'data_file': [message]}, status=status.HTTP_400_BAD_REQUEST) @@ -168,6 +188,72 @@ def check_delete_permissions(self, request, dataset): if sub.phase: return 'Cannot delete submission: submission belongs to an existing competition. Please visit the competition and delete your submission from there.' + @action(detail=False, methods=('GET',), pagination_class=LargePagination) + def public(self, request): + """ + Retrieve a public list of datasets with optional filtering and ordering. + + This endpoint returns a paginated list of datasets that are public. + It supports several optional query parameters for filtering and sorting the results. + + Query Parameters: + ----------------- + - search (str, optional): A search term to filter competitions by their title. + - ordering (str, optional): Specifies the order of the results. Supported values: + * "recently_added" - Most recently created datasets. + * "most_downloaded" - Datasets with the most downloads. + Defaults to "recently_added" if not provided or invalid. + - has_license (bool, optional): If "true", filters datasets that has license. + - is_verified (bool, optional): If "true", filters datasets that are verified. + + Returns: + -------- + - 200 OK: A paginated or full list of serialized datasets matching the filter criteria. The response is serialized using `DatasetSerializer`. + """ + + # Receive filters from request query params + search = request.query_params.get("search") + ordering = request.query_params.get("ordering") + has_license = request.query_params.get("has_license", "false").lower() == "true" + is_verified = request.query_params.get("is_verified", "false").lower() == "true" + + qs = Data.objects.filter( + is_public=True, + type=Data.PUBLIC_DATA + ) + + # Filter by title and description (search) + if search: + qs = qs.filter( + Q(name__icontains=search) | + Q(description__icontains=search) + ) + + # Filter by has_license + if has_license: + qs = qs.filter(license__isnull=False) + + # Filter by is_verified + if is_verified: + qs = qs.filter(is_verified=True) + + # Apply ordering + if ordering == "recently_added": + qs = qs.order_by("-id") # most recently created + elif ordering == "most_downloaded": + qs = qs.order_by("-downloads") # descending by download count + else: + qs = qs.order_by("-id") # default fallback + + queryset = self.filter_queryset(qs) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class DataGroupViewSet(ModelViewSet): queryset = DataGroup.objects.all() diff --git a/src/apps/datasets/migrations/0011_auto_20250724_1050.py b/src/apps/datasets/migrations/0011_auto_20250724_1050.py new file mode 100644 index 000000000..6c4ba0c63 --- /dev/null +++ b/src/apps/datasets/migrations/0011_auto_20250724_1050.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.28 on 2025-07-24 10:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0010_auto_20250218_1100'), + ] + + operations = [ + migrations.AddField( + model_name='data', + name='downloads', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='data', + name='is_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='data', + name='license', + field=models.CharField(blank=True, max_length=128, null=True), + ), + ] diff --git a/src/apps/datasets/models.py b/src/apps/datasets/models.py index 67edb7343..4e579bbdb 100644 --- a/src/apps/datasets/models.py +++ b/src/apps/datasets/models.py @@ -68,6 +68,10 @@ class Data(ChaHubSaveMixin, models.Model): competition = models.ForeignKey(Competition, on_delete=models.CASCADE, null=True, related_name='submission') file_name = models.CharField(max_length=64, default="") + is_verified = models.BooleanField(default=False) + downloads = models.PositiveIntegerField(default=0) + license = models.CharField(max_length=128, null=True, blank=True) + def get_download_url(self): return reverse('datasets:download', kwargs={'key': self.key}) diff --git a/src/apps/datasets/urls.py b/src/apps/datasets/urls.py index 3063c612a..7b36e6178 100644 --- a/src/apps/datasets/urls.py +++ b/src/apps/datasets/urls.py @@ -7,5 +7,9 @@ urlpatterns = [ # path('', views.CompetitionList.as_view(), name="list"), path('', views.DataManagement.as_view(), name="management"), + path('public/', views.DatasetsPublic.as_view(), name="public"), + path('create/', views.DatasetCreate.as_view(), name="create"), path('download//', views.download, name="download"), + path('/', views.DatasetDetail.as_view(), name="detail"), + path('download/id//', views.download_by_pk, name="download_by_pk"), ] diff --git a/src/apps/datasets/views.py b/src/apps/datasets/views.py index f7146c422..b6c312970 100644 --- a/src/apps/datasets/views.py +++ b/src/apps/datasets/views.py @@ -1,16 +1,64 @@ from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseRedirect +from django.http import HttpResponseRedirect, Http404 from django.shortcuts import get_object_or_404 -from django.views.generic import TemplateView +from django.views.generic import TemplateView, DetailView from datasets.models import Data from utils.data import make_url_sassy +from api.serializers.datasets import DatasetSerializer class DataManagement(LoginRequiredMixin, TemplateView): template_name = 'datasets/management.html' +class DatasetsPublic(TemplateView): + template_name = 'datasets/public.html' + + +class DatasetCreate(LoginRequiredMixin, TemplateView): + template_name = 'datasets/create.html' + + +class DatasetDetail(DetailView): + queryset = Data.objects.filter(type__in=[Data.PUBLIC_DATA, Data.INPUT_DATA, Data.REFERENCE_DATA]) + template_name = 'datasets/detail.html' + + def get_object(self, *args, **kwargs): + dataset = super().get_object(*args, **kwargs) + + # If dataset is public or (user is authenticated and is owner), return dataset + if dataset.is_public or ( + self.request.user.is_authenticated and dataset.created_by == self.request.user + ): + return dataset + + # Otherwise return 404 + raise Http404() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + dataset = context["object"] + + serializer = DatasetSerializer(dataset) + context["object"] = serializer.data + return context + + def download(request, key): data = get_object_or_404(Data, key=key) return HttpResponseRedirect(make_url_sassy(data.data_file.name)) + + +def download_by_pk(request, pk): + dataset = get_object_or_404(Data, pk=pk) + + if dataset.is_public or dataset.created_by == request.user: + # Increment download count + dataset.downloads = (dataset.downloads or 0) + 1 + dataset.save(update_fields=["downloads"]) + + # Redirect to the actual file URL + return HttpResponseRedirect(make_url_sassy(dataset.data_file.name)) + + raise Http404() diff --git a/src/static/js/ours/client.js b/src/static/js/ours/client.js index 410ec34cf..642a3ccfd 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -186,6 +186,9 @@ CODALAB.api = { delete_datasets: function(pk_list) { return CODALAB.api.request('POST', `${URLS.API}datasets/delete_many/`, pk_list) }, + get_public_datasets: function (query) { + return CODALAB.api.request('GET', URLS.API + "datasets/public/", query) + }, /** * Creates a dataset * @param {object} metadata - name, description, type, data_file, is_public diff --git a/src/static/riot/competitions/public-list.tag b/src/static/riot/competitions/public-list.tag index c84c764f2..75e31369d 100644 --- a/src/static/riot/competitions/public-list.tag +++ b/src/static/riot/competitions/public-list.tag @@ -1,6 +1,16 @@ -

Public Benchmarks and Competitions

+
@@ -283,11 +293,37 @@ display block margin-bottom 5px + .page-header + display flex + align-items center + justify-content space-between + margin-bottom 20px + + .action-buttons + display flex + gap 10px + .page-title - margin 0 0 20px 0 + margin 0 font-size 24px font-weight bold color #1b1b1b + + .create-btn + font-size 14px + padding 0.5em 1em + background-color #43637a + color #fff + text-decoration none + border-radius 4px + display inline-block + cursor pointer + transition background-color 0.2s ease + + &:hover + background-color #2d3f4d + color #fff + text-decoration none .content-container display flex @@ -302,16 +338,16 @@ margin-left 0 !important background #f9f9f9 - input[type="text"] - width 100% - padding 5px - margin 5px 0 5px 0 - border 1px solid #ddd - border-radius 4px + input[type="text"] + width 100% + padding 5px + margin 5px 0 5px 0 + border 1px solid #ddd + border-radius 4px - input[type="radio"], - input[type="checkbox"] - margin-right 5px + input[type="radio"], + input[type="checkbox"] + margin-right 5px .filter-group margin-bottom 20px @@ -362,10 +398,10 @@ margin-bottom 6px .tile-wrapper:hover - box-shadow 0 3px 4px -1px #9c9c9c + box-shadow 0 3px 4px -1px #cac9c9ff transition all 75ms ease-in-out - background-color #e8e8e8 - border solid 1px #b9b9b9 + background-color #e6edf2 + border solid 1px #a5b7c5 .comp-stats background-color #344d5e diff --git a/src/static/riot/datasets/create.tag b/src/static/riot/datasets/create.tag new file mode 100644 index 000000000..4cf434685 --- /dev/null +++ b/src/static/riot/datasets/create.tag @@ -0,0 +1,240 @@ + +
+

Create Dataset

+ +
+ + +
+
+ Errorn (s) creating dataset +
+
    +
  • + {field}: {error} +
  • +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +

+ NOTE: Only datasets with type: public data are listed on the Public Datasets page; input and reference data appear only in your Resources page. +

+
+ + +
+ +
+ + +
+

+ NOTE: Only datasets that are marked `public` are listed on the Public Datasets. +

+
+ + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+
+ + + + + +
diff --git a/src/static/riot/datasets/detail.tag b/src/static/riot/datasets/detail.tag new file mode 100644 index 000000000..3fd3a61b6 --- /dev/null +++ b/src/static/riot/datasets/detail.tag @@ -0,0 +1,96 @@ + +
+

Dataset Details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{dataset.name}
Description{dataset.description}
Owner{dataset.created_by}
Uploaded Date{pretty_date(dataset.created_when)}
License{dataset.license}
Downloads{dataset.downloads}
VerifiedNo
File Size{pretty_bytes(dataset.file_size)}
+ + Download + +
+
+ + + + +
diff --git a/src/static/riot/datasets/public-list.tag b/src/static/riot/datasets/public-list.tag new file mode 100644 index 000000000..56c3c5679 --- /dev/null +++ b/src/static/riot/datasets/public-list.tag @@ -0,0 +1,383 @@ + + + + + +
+ + +
+

Filters

+ +
+ +
+ +
+
+ +
+ Order By + + +
+ +
+ Dataset properties + + +
+ +
+ +
+
+ + +
+
+
+
+ + + + +
+
+
No datasets found
+ Try changing your filters or search term. +
+
+ +
+ + + { current_page } of {Math.ceil(datasets.count / datasets.page_size)} + + +
+
+
+ + + + +
diff --git a/src/templates/base.html b/src/templates/base.html index 30584b35a..81aa897ff 100644 --- a/src/templates/base.html +++ b/src/templates/base.html @@ -95,76 +95,48 @@