diff --git a/README.md b/README.md index 3dcbd62d2..2b3308d0f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ -![CodaBench logo](src/static/img/codabench_black.png) [![Circle CI](https://circleci.com/gh/codalab/codabench.svg?style=shield)](https://app.circleci.com/pipelines/github/codalab/codabench) +![Codabench logo](src/static/img/codabench_black.png) [![Circle CI](https://circleci.com/gh/codalab/codabench.svg?style=shield)](https://app.circleci.com/pipelines/github/codalab/codabench) -## What is CodaBench? +## What is Codabench? -CodaBench is an open-source web-based platform that enables researchers, developers, and data scientists to collaborate, with the goal of advancing research fields where machine learning and advanced computation is used. CodaBench helps to solve many common problems in the arena of data-oriented research through its online community where people can share worksheets and participate in competitions and benchmarks. It can be seen as a version 2 of [CodaLab Competitions](https://github.com/codalab/codalab-competitions). +Codabench is an open-source web-based platform that enables researchers, developers, and data scientists to collaborate, with the goal of advancing research fields where machine learning and advanced computation is used. Codabench helps to solve many common problems in the arena of data-oriented research through its online community where people can share worksheets and participate in competitions and benchmarks. It can be seen as a version 2 of [CodaLab Competitions](https://github.com/codalab/codalab-competitions). -To see CodaBench in action, visit [codabench.org](https://www.codabench.org/). +To see Codabench in action, visit [codabench.org](https://www.codabench.org/). ## Documentation -- [CodaBench Wiki](https://github.com/codalab/codabench/wiki) +- [Codabench Wiki](https://github.com/codalab/codabench/wiki) ## Quick installation (for Linux) _To participate, or even organize your own benchmarks or competitions, **you don't need to install anything**, you just need to sign in an instance of the platform (e.g. [this one](https://www.codabench.org/)). -If you wish to configure your own instance of CodaBench platform, here are the instructions:_ +If you wish to configure your own instance of Codabench platform, here are the instructions:_ ``` @@ -40,7 +40,7 @@ The text of the Apache License 2.0 can be found online at: http://www.opensource.org/licenses/apache2.0.php -## Cite CodaBench in your research +## Cite Codabench in your research ``` @article{codabench, diff --git a/src/apps/announcements/__init__.py b/src/apps/announcements/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/announcements/admin.py b/src/apps/announcements/admin.py new file mode 100644 index 000000000..e675dd528 --- /dev/null +++ b/src/apps/announcements/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from . import models + +admin.site.register(models.Announcement) +admin.site.register(models.NewsPost) diff --git a/src/apps/announcements/apps.py b/src/apps/announcements/apps.py new file mode 100644 index 000000000..1a07a47b4 --- /dev/null +++ b/src/apps/announcements/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AnnouncementsConfig(AppConfig): + name = 'announcements' diff --git a/src/apps/announcements/migrations/0001_initial.py b/src/apps/announcements/migrations/0001_initial.py new file mode 100644 index 000000000..a04637dc3 --- /dev/null +++ b/src/apps/announcements/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 2.2.17 on 2023-06-15 18:12 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Announcement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='NewsPost', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=40, unique=True)), + ('link', models.URLField()), + ('created_when', models.DateTimeField(default=django.utils.timezone.now)), + ('text', models.TextField(blank=True, null=True)), + ], + ), + ] diff --git a/src/apps/announcements/migrations/0002_auto_20230615_2012.py b/src/apps/announcements/migrations/0002_auto_20230615_2012.py new file mode 100644 index 000000000..1e446cb6c --- /dev/null +++ b/src/apps/announcements/migrations/0002_auto_20230615_2012.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2023-06-15 20:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('announcements', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='newspost', + name='title', + field=models.CharField(max_length=40), + ), + ] diff --git a/src/apps/announcements/migrations/0003_auto_20230616_1326.py b/src/apps/announcements/migrations/0003_auto_20230616_1326.py new file mode 100644 index 000000000..c17efe978 --- /dev/null +++ b/src/apps/announcements/migrations/0003_auto_20230616_1326.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2023-06-16 13:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('announcements', '0002_auto_20230615_2012'), + ] + + operations = [ + migrations.AlterField( + model_name='newspost', + name='link', + field=models.URLField(blank=True), + ), + ] diff --git a/src/apps/announcements/migrations/__init__.py b/src/apps/announcements/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/announcements/models.py b/src/apps/announcements/models.py new file mode 100644 index 000000000..fbb0a4d1f --- /dev/null +++ b/src/apps/announcements/models.py @@ -0,0 +1,13 @@ +from django.db import models +from django.utils.timezone import now + + +class Announcement(models.Model): + text = models.TextField(null=True, blank=True) + + +class NewsPost(models.Model): + title = models.CharField(max_length=40) + link = models.URLField(max_length=200, blank=True) + created_when = models.DateTimeField(default=now) + text = models.TextField(null=True, blank=True) diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 0314d831d..4d79c9a61 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -15,11 +15,13 @@ from tasks.models import Task from api.serializers.queues import QueueSerializer +from datetime import datetime class PhaseSerializer(WritableNestedModelSerializer): tasks = serializers.SlugRelatedField(queryset=Task.objects.all(), required=True, allow_null=False, slug_field='key', many=True) + status = serializers.SerializerMethodField() class Meta: model = Phase @@ -42,6 +44,42 @@ class Meta: 'is_final_phase', ) + def get_status(self, obj): + + now = datetime.now().replace(tzinfo=None) + start = obj.start.replace(tzinfo=None) + end = obj.end.replace(tzinfo=None) if obj.end else obj.end + phase_ended = False + phase_started = False + + # check if phase has started + if start > now: + # start date is in the future, phase started = NO + phase_started = False + else: + # start date is not in the future, phase started = YES + phase_started = True + + if phase_started: + # check if end date exists for this phase + if end: + if end < now: + # Phase cannote accept submissions if end date is in the past + phase_ended = True + else: + # Phase can accept submissions if end date is in the future + phase_ended = False + else: + # Phase can accept submissions if end date is not given + phase_ended = False + + if phase_started and phase_ended: + return Phase.PREVIOUS + elif phase_started and (not phase_ended): + return Phase.CURRENT + elif not phase_started: + return Phase.NEXT + def validate_leaderboard(self, value): if not value: raise ValidationError("Phases require a leaderboard") @@ -50,6 +88,7 @@ def validate_leaderboard(self, value): class PhaseDetailSerializer(serializers.ModelSerializer): tasks = PhaseTaskInstanceSerializer(source='task_instances', many=True) + status = serializers.SerializerMethodField() class Meta: model = Phase @@ -71,6 +110,42 @@ class Meta: 'is_final_phase', ) + def get_status(self, obj): + + now = datetime.now().replace(tzinfo=None) + start = obj.start.replace(tzinfo=None) + end = obj.end.replace(tzinfo=None) if obj.end else obj.end + phase_ended = False + phase_started = False + + # check if phase has started + if start > now: + # start date is in the future, phase started = NO + phase_started = False + else: + # start date is not in the future, phase started = YES + phase_started = True + + if phase_started: + # check if end date exists for this phase + if end: + if end < now: + # Phase cannote accept submissions if end date is in the past + phase_ended = True + else: + # Phase can accept submissions if end date is in the future + phase_ended = False + else: + # Phase can accept submissions if end date is not given + phase_ended = False + + if phase_started and phase_ended: + return Phase.PREVIOUS + elif phase_started and (not phase_ended): + return Phase.CURRENT + elif not phase_started: + return Phase.NEXT + class PhaseUpdateSerializer(PhaseSerializer): tasks = PhaseTaskInstanceSerializer(source='task_instances', many=True) @@ -262,6 +337,18 @@ class Meta: class CompetitionParticipantSerializer(serializers.ModelSerializer): username = serializers.CharField(source='user.username') is_bot = serializers.BooleanField(source='user.is_bot') + + class Meta: + model = CompetitionParticipant + fields = ( + 'username', + 'is_bot', + ) + + +class CompetitionParticipantWithEmailSerializer(serializers.ModelSerializer): + username = serializers.CharField(source='user.username') + is_bot = serializers.BooleanField(source='user.is_bot') email = serializers.CharField(source='user.email') class Meta: diff --git a/src/apps/api/tests/test_cleanup.py b/src/apps/api/tests/test_cleanup.py new file mode 100644 index 000000000..a927800af --- /dev/null +++ b/src/apps/api/tests/test_cleanup.py @@ -0,0 +1,134 @@ +from rest_framework.test import APITestCase +from django.urls import reverse +import json +from factories import ( + UserFactory, + CompetitionFactory, + PhaseFactory, + TaskFactory, + SubmissionFactory, + DataFactory +) +from competitions.models import Submission +from datasets.models import Data + + +class CleanUpTests(APITestCase): + def setUp(self): + + # Create a user + user = UserFactory(username='test_user', password='test_user') + + # Create a competition + comp = CompetitionFactory(created_by=user) + + # Create used tasks + self.used_tasks = [ + TaskFactory(created_by=user), + TaskFactory(created_by=user) + ] + + # Create unused task + self.unused_tasks = [ + TaskFactory(created_by=user), + TaskFactory(created_by=user) + ] + + # Create phase with used tasks + phase = PhaseFactory(competition=comp, tasks=self.used_tasks) + + # Create used-failed submission + self.failed_submissions = [SubmissionFactory( + phase=phase, + owner=user, + status=Submission.FAILED, + data=DataFactory(created_by=user, type=Data.SUBMISSION, competition=comp) + )] + + # Create unused submission + self.unused_submissions = [ + DataFactory(created_by=user, type=Data.SUBMISSION), + DataFactory(created_by=user, type=Data.SUBMISSION) + ] + + # Create unused datasets and programs + self.unused_datasets_programs = [ + DataFactory(created_by=user, type=Data.INGESTION_PROGRAM), + DataFactory(created_by=user, type=Data.SCORING_PROGRAM), + DataFactory(created_by=user, type=Data.INPUT_DATA), + DataFactory(created_by=user, type=Data.REFERENCE_DATA), + DataFactory(created_by=user, type=Data.PUBLIC_DATA) + ] + + self.client.login(username='test_user', password='test_user') + + def test_cleanup_stats(self): + + url = reverse('user_quota_cleanup') + resp = self.client.get(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["unused_tasks"] == len(self.unused_tasks) + assert content["unused_datasets_programs"] == len(self.unused_datasets_programs) + assert content["unused_submissions"] == len(self.unused_submissions) + assert content["failed_submissions"] == len(self.failed_submissions) + + def test_delete_unused_tasks(self): + + url = reverse('delete_unused_tasks') + resp = self.client.delete(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["success"] + assert content["message"] == "Unused tasks deleted successfully" + + url = reverse('user_quota_cleanup') + resp = self.client.get(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["unused_tasks"] == 0 + + def test_delete_unused_datasets(self): + + url = reverse('delete_unused_datasets') + resp = self.client.delete(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["success"] + assert content["message"] == "Unused datasets and programs deleted successfully" + + url = reverse('user_quota_cleanup') + resp = self.client.get(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["unused_datasets_programs"] == 0 + + def test_delete_unused_submissions(self): + + url = reverse('delete_unused_submissions') + resp = self.client.delete(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["success"] + assert content["message"] == "Unused submissions deleted successfully" + + url = reverse('user_quota_cleanup') + resp = self.client.get(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["unused_submissions"] == 0 + + def test_delete_failed_submissions(self): + + url = reverse('delete_failed_submissions') + resp = self.client.delete(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["success"] + assert content["message"] == "Failed submissions deleted successfully" + + url = reverse('user_quota_cleanup') + resp = self.client.get(url) + assert resp.status_code == 200 + content = json.loads(resp.content) + assert content["failed_submissions"] == 0 diff --git a/src/apps/api/urls.py b/src/apps/api/urls.py index 2f79d0eff..0bb521b3e 100644 --- a/src/apps/api/urls.py +++ b/src/apps/api/urls.py @@ -7,7 +7,17 @@ from rest_framework.permissions import AllowAny from rest_framework.urlpatterns import format_suffix_patterns -from .views import analytics, competitions, datasets, profiles, leaderboards, submissions, tasks, queues +from .views import ( + analytics, + competitions, + datasets, + profiles, + leaderboards, + submissions, + tasks, + queues, + quota +) router = SimpleRouter() @@ -45,6 +55,13 @@ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), path('api-token-auth/', obtain_auth_token), + # User quota and cleanup + path('user_quota_cleanup/', quota.user_quota_cleanup, name="user_quota_cleanup"), + path('delete_unused_tasks/', quota.delete_unused_tasks, name="delete_unused_tasks"), + path('delete_unused_datasets/', quota.delete_unused_datasets, name="delete_unused_datasets"), + path('delete_unused_submissions/', quota.delete_unused_submissions, name="delete_unused_submissions"), + path('delete_failed_submissions/', quota.delete_failed_submissions, name="delete_failed_submissions"), + # API Docs re_path(r'docs(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index d27a3fe5e..2db303a45 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -25,6 +25,7 @@ from rest_framework.viewsets import ModelViewSet from api.serializers.competitions import CompetitionSerializerSimple, PhaseSerializer, \ CompetitionCreationTaskStatusSerializer, CompetitionDetailSerializer, CompetitionParticipantSerializer, \ + CompetitionParticipantWithEmailSerializer,\ FrontPageCompetitionsSerializer, PhaseResultsSerializer, CompetitionUpdateSerializer, CompetitionCreateSerializer from api.serializers.leaderboards import LeaderboardPhaseSerializer, LeaderboardSerializer from competitions.emails import send_participation_requested_emails, send_participation_accepted_emails, \ @@ -35,8 +36,8 @@ from leaderboards.models import Leaderboard from utils.data import make_url_sassy from api.permissions import IsOrganizerOrCollaborator -import logging -logger = logging.getLogger() +from datetime import datetime +from django.db import transaction class CompetitionViewSet(ModelViewSet): @@ -184,34 +185,58 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def update(self, request, *args, **kwargs): - """Mostly a copy of the underlying base update, however we return some additional data - in the response to remove a GET from the frontend""" - partial = kwargs.pop('partial', False) - instance = self.get_object() - data = request.data - # TODO - This is Temporary. Need to change Leaderboard to Phase connect to M2M and handle this correctly. - # save leaderboard individually, then pass pk to each phase - if 'leaderboards' in data: - leaderboard_data = data['leaderboards'][0] - if(leaderboard_data['id']): - leaderboard_instance = Leaderboard.objects.get(id=leaderboard_data['id']) - leaderboard = LeaderboardSerializer(leaderboard_instance, data=data['leaderboards'][0]) - else: - leaderboard = LeaderboardSerializer(data=data['leaderboards'][0]) - leaderboard.is_valid() - leaderboard.save() - leaderboard_id = leaderboard["id"].value - for phase in data['phases']: - phase['leaderboard'] = leaderboard_id - - serializer = self.get_serializer(instance, data=data, partial=partial) - serializer.is_valid(raise_exception=True) - self.perform_update(serializer) - - if getattr(instance, '_prefetched_objects_cache', None): - # If 'prefetch_related' has been applied to a queryset, we need to - # forcibly invalidate the prefetch cache on the instance. - instance._prefetched_objects_cache = {} + # Execute everything inside atomic transaction + with transaction.atomic(): + """Mostly a copy of the underlying base update, however we return some additional data + in the response to remove a GET from the frontend""" + partial = kwargs.pop('partial', False) + instance = self.get_object() + data = request.data + # TODO - This is Temporary. Need to change Leaderboard to Phase connect to M2M and handle this correctly. + # save leaderboard individually, then pass pk to each phase + if 'leaderboards' in data: + leaderboard_data = data['leaderboards'][0] + if(leaderboard_data['id']): + leaderboard_instance = Leaderboard.objects.get(id=leaderboard_data['id']) + leaderboard = LeaderboardSerializer(leaderboard_instance, data=data['leaderboards'][0]) + else: + leaderboard = LeaderboardSerializer(data=data['leaderboards'][0]) + leaderboard.is_valid() + leaderboard.save() + leaderboard_id = leaderboard["id"].value + + for phase in data['phases']: + # Newly added phase from front-end has no id + # Add a phase first to get id + # add this phase id in each task + if 'id' not in phase: + # Create Phase object + new_phase_obj = Phase.objects.create( + status=phase["status"], + index=phase["index"], + start=datetime.strptime(phase['start'], "%B %d, %Y"), + end=datetime.strptime(phase['end'], "%B %d, %Y") if phase['end'] else None, + name=phase["name"], + description=phase["description"], + hide_output=phase["hide_output"], + competition=Competition.objects.get(id=data['id']) + ) + # Get phase id + new_phase_id = new_phase_obj.id + # loop over phase tasks to add phase id in each task + for task in phase["tasks"]: + task['phase'] = new_phase_id + + phase['leaderboard'] = leaderboard_id + + serializer = self.get_serializer(instance, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + if getattr(instance, '_prefetched_objects_cache', None): + # If 'prefetch_related' has been applied to a queryset, we need to + # forcibly invalidate the prefetch cache on the instance. + instance._prefetched_objects_cache = {} # Re-do serializer in detail version (i.e. for Collaborator data) context = self.get_serializer_context() @@ -518,9 +543,14 @@ def get_leaderboard(self, request, pk): columns = [col for col in query['columns']] submissions_keys = {} for submission in query['submissions']: - # count number of entries/number of submissions for the owner of this submission for this phase - num_entries = Submission.objects.filter(owner__username=submission['owner'], phase=phase).count() + # count all submissions with no parent and count all parents without counting the children + num_entries = Submission.objects.filter( + Q(owner__username=submission['owner']) | Q(parent__owner__username=submission['owner']), + phase=phase, + ).exclude( + parent__isnull=False + ).count() # get date of last submission by the owner of this submission for this phase last_entry_date = Submission.objects.filter(owner__username=submission['owner'], phase=phase)\ @@ -575,7 +605,6 @@ def get_leaderboard(self, request, pk): class CompetitionParticipantViewSet(ModelViewSet): queryset = CompetitionParticipant.objects.all() - serializer_class = CompetitionParticipantSerializer filter_backends = (DjangoFilterBackend, SearchFilter) filter_fields = ('user__username', 'user__email', 'status', 'competition') search_fields = ('user__username', 'user__email',) @@ -588,6 +617,14 @@ def get_queryset(self): qs = qs.select_related('user').order_by('user__username') return qs + def get_serializer_class(self): + + participants_with_email = self.request.query_params.get('participants_with_email', None) + if participants_with_email: + return CompetitionParticipantWithEmailSerializer + else: + return CompetitionParticipantSerializer + def update(self, request, *args, **kwargs): if request.method == 'PATCH': if 'status' in request.data: diff --git a/src/apps/api/views/profiles.py b/src/apps/api/views/profiles.py index c34fb9482..6492a5efb 100644 --- a/src/apps/api/views/profiles.py +++ b/src/apps/api/views/profiles.py @@ -229,3 +229,25 @@ def validate_invite(self, request): mem_ser = MembershipSerializer(membership) return Response(mem_ser.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=['delete']) + def delete_organization(self, request, pk=None): + try: + org = Organization.objects.get(id=pk) + member = org.membership_set.get(user=request.user) + if member.group == Membership.OWNER: + org.delete() + return Response({ + "success": True, + "message": "Organization deleted!" + }) + else: + return Response({ + "success": False, + "message": "You do not have delete rights!" + }) + except Exception as e: + return Response({ + "success": False, + "message": f"{e}" + }) diff --git a/src/apps/api/views/quota.py b/src/apps/api/views/quota.py new file mode 100644 index 000000000..4b2fe0ff5 --- /dev/null +++ b/src/apps/api/views/quota.py @@ -0,0 +1,133 @@ +from django.db.models import Q +from rest_framework.decorators import api_view +from rest_framework.response import Response +from datasets.models import Data +from tasks.models import Task +from competitions.models import Submission + + +@api_view(['GET']) +def user_quota_cleanup(request): + + # Get Unused tasks count + unused_tasks = Task.objects.filter( + created_by=request.user, + phases__isnull=True + ).count() + + # Get Unused datasets and programs count + unused_datasets_programs = Data.objects.filter( + Q(created_by=request.user) & + ~Q(type=Data.SUBMISSION) & + ~Q(type=Data.COMPETITION_BUNDLE) + ).exclude( + Q(task_ingestion_programs__isnull=False) | + Q(task_input_datas__isnull=False) | + Q(task_reference_datas__isnull=False) | + Q(task_scoring_programs__isnull=False) + ).count() + + # Get Unused submissions count + unused_submissions = Data.objects.filter( + Q(created_by=request.user) & + Q(type=Data.SUBMISSION) & + Q(competition__isnull=True) + ).count() + + # Get Failed submissions count + failed_submissions = Submission.objects.filter( + Q(owner=request.user) & + Q(status=Submission.FAILED) + ).count() + + return Response({ + "unused_tasks": unused_tasks, + "unused_datasets_programs": unused_datasets_programs, + "unused_submissions": unused_submissions, + "failed_submissions": failed_submissions + }) + + +@api_view(['DELETE']) +def delete_unused_tasks(request): + try: + + Task.objects.filter( + created_by=request.user, + phases__isnull=True + ).delete() + + return Response({ + "success": True, + "message": "Unused tasks deleted successfully" + }) + except Exception as e: + return Response({ + "success": False, + "message": f"{e}" + }) + + +@api_view(['DELETE']) +def delete_unused_datasets(request): + try: + Data.objects.filter( + Q(created_by=request.user) & + ~Q(type=Data.SUBMISSION) & + ~Q(type=Data.COMPETITION_BUNDLE) + ).exclude( + Q(task_ingestion_programs__isnull=False) | + Q(task_input_datas__isnull=False) | + Q(task_reference_datas__isnull=False) | + Q(task_scoring_programs__isnull=False) + ).delete() + + return Response({ + "success": True, + "message": "Unused datasets and programs deleted successfully" + }) + except Exception as e: + return Response({ + "success": False, + "message": f"{e}" + }) + + +@api_view(['DELETE']) +def delete_unused_submissions(request): + try: + + Data.objects.filter( + Q(created_by=request.user) & + Q(type=Data.SUBMISSION) & + Q(competition__isnull=True) + ).delete() + + return Response({ + "success": True, + "message": "Unused submissions deleted successfully" + }) + except Exception as e: + return Response({ + "success": False, + "message": f"{e}" + }) + + +@api_view(['DELETE']) +def delete_failed_submissions(request): + try: + Submission.objects.filter( + Q(owner=request.user) & + Q(status=Submission.FAILED) + ).delete() + + return Response({ + "success": True, + "message": "Failed submissions deleted successfully" + }) + except Exception as e: + return Response({ + "success": False, + "message": f"{e}" + }) diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index 441cfe40e..7395d416a 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -7,6 +7,7 @@ from competitions.models import Competition, Submission, CompetitionParticipant from profiles.models import User +from announcements.models import Announcement, NewsPost from django.shortcuts import render @@ -36,6 +37,13 @@ def get_context_data(self, *args, **kwargs): {'label': "Competition Participants", 'count': competition_participants}, {'label': "Submissions", 'count': submissions}, ] + + announcement = Announcement.objects.all().first() + context['announcement'] = announcement.text if announcement else None + + news_posts = NewsPost.objects.all().order_by('-id') + context['news_posts'] = news_posts + return context diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index a6b92e944..7c18e21bf 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -89,7 +89,7 @@ def get_full_name(self): return self.name def __str__(self): - return f'{self.username} | {self.email}' + return self.username @property def slug_url(self): diff --git a/src/settings/base.py b/src/settings/base.py index 64ab54589..1366a0cc8 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -58,6 +58,7 @@ 'queues', 'health', 'forums', + 'announcements', ) INSTALLED_APPS = THIRD_PARTY_APPS + OUR_APPS diff --git a/src/static/js/ours/client.js b/src/static/js/ours/client.js index 5a912ffb4..f3e1a21dc 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -276,6 +276,9 @@ CODALAB.api = { delete_organization_member: (id, data) => { return CODALAB.api.request('DELETE', `${URLS.API}organizations/${id}/delete_member/`, data) }, + delete_organization: (id) => { + return CODALAB.api.request('DELETE', `${URLS.API}organizations/${id}/delete_organization/`) + }, /*--------------------------------------------------------------------- Participants ---------------------------------------------------------------------*/ @@ -294,4 +297,23 @@ CODALAB.api = { get_analytics: (filters) => { return CODALAB.api.request('GET', `${URLS.API}analytics/`, filters) }, + /*--------------------------------------------------------------------- + User Quota and Cleanup + ---------------------------------------------------------------------*/ + get_user_quota_cleanup: () => { + return CODALAB.api.request('GET', `${URLS.API}user_quota_cleanup/`) + }, + delete_unused_tasks: () => { + return CODALAB.api.request('DELETE', `${URLS.API}delete_unused_tasks/`) + }, + delete_unused_datasets: () => { + return CODALAB.api.request('DELETE', `${URLS.API}delete_unused_datasets/`) + }, + delete_unused_submissions: () => { + return CODALAB.api.request('DELETE', `${URLS.API}delete_unused_submissions/`) + }, + delete_failed_submissions: () => { + return CODALAB.api.request('DELETE', `${URLS.API}delete_failed_submissions/`) + }, + } diff --git a/src/static/riot/competitions/detail/_tabs.tag b/src/static/riot/competitions/detail/_tabs.tag index 3ef4279b3..380ba377f 100644 --- a/src/static/riot/competitions/detail/_tabs.tag +++ b/src/static/riot/competitions/detail/_tabs.tag @@ -2,7 +2,6 @@
- - -
@@ -289,40 +206,6 @@ }) }) - // loop over competition phases to mark if phase has started or ended - self.competition.phases.forEach(function (phase, index) { - - phase_ended = false - phase_started = false - - // check if phase has started - if((Date.parse(phase["start"]) - Date.parse(new Date())) > 0){ - // start date is in the future, phase started = NO - phase_started = false - }else{ - // start date is not in the future, phase started = YES - phase_started = true - } - - if(phase_started){ - // check if end data exists for this phase - if(phase["end"]){ - if((Date.parse(phase["end"]) - Date.parse(new Date())) < 0){ - // Phase cannote accept submissions if end date is in the past - phase_ended = true - }else{ - // Phase can accept submissions if end date is in the future - phase_ended = false - } - }else{ - // Phase can accept submissions if end date is not given - phase_ended = false - } - } - self.competition.phases[index]["phase_ended"] = phase_ended - self.competition.phases[index]["phase_started"] = phase_started - }) - self.competition.is_admin = CODALAB.state.user.has_competition_admin_privileges(competition) self.selected_phase_index = _.get(_.find(self.competition.phases, {'status': 'Current'}), 'id') if (self.selected_phase_index == null) { diff --git a/src/static/riot/competitions/detail/participant_manager.tag b/src/static/riot/competitions/detail/participant_manager.tag index e894735c4..58bf48422 100644 --- a/src/static/riot/competitions/detail/participant_manager.tag +++ b/src/static/riot/competitions/detail/participant_manager.tag @@ -134,6 +134,7 @@ if (status && status !== '-') { filters.status = status } + filters.participants_with_email = true CODALAB.api.get_participants(filters) .done(participants => { diff --git a/src/static/riot/competitions/detail/submission_upload.tag b/src/static/riot/competitions/detail/submission_upload.tag index 76728a5ee..e6a4e5289 100644 --- a/src/static/riot/competitions/detail/submission_upload.tag +++ b/src/static/riot/competitions/detail/submission_upload.tag @@ -1,11 +1,10 @@ -
+

Submission upload

-
This phase has ended and no longer accepts submissions!
-
This phase hasn't started yet!
+
This phase has ended and no longer accepts submissions!
+
This phase hasn't started yet!

Metadata or Fact Sheet

@@ -51,11 +50,20 @@
- + + + + +
@@ -180,8 +188,19 @@ $(self.refs.data_file.refs.file_input).on('change', self.check_can_upload) self.setup_autoscroll() self.setup_websocket() + }) + + // Function to capture change of `submit as` dropdown + // Redirect to Add organization if selected option is Add Organizaiton + $(document).on('change','#organization_dropdown',function(){ + let selected_option_value = $('#organization_dropdown option:selected').val(); + if(selected_option_value == 'add_organization'){ + // Open Add organization in new tab + window.open('/profiles/organization/create/', '_blank') + } }) + self.setup_autoscroll = function () { if (!self.refs.autoscroll_checkbox) { return @@ -353,7 +372,7 @@ self.check_can_upload = function () { // Check if selected phase accepts submissions (within the deadline of the phase) - if(self.selected_phase.phase_started && !self.selected_phase.phase_ended){ + if(self.selected_phase.status === "Current"){ CODALAB.api.can_make_submissions(self.selected_phase.id) .done(function (data) { @@ -368,13 +387,12 @@ }) }else{ // Error when phase is not accepting submissions - if(!self.selected_phase.phase_started){ + if(self.selected_phase.status === "Next"){ toastr.error('This phase has not started yet. Please check the phase start date!') - }else { - if(self.selected_phase.phase_ended){ - toastr.error('This phase has ended and no longer accepts submissions!') - } + } + if(self.selected_phase.status === "Previous"){ + toastr.error('This phase has ended and no longer accepts submissions!') } self.clear_form() } @@ -424,7 +442,9 @@ self.lines = {} let dropdown = $('#organization_dropdown') let organization = dropdown.dropdown('get value') - organization = organization === 'None' ? null : organization + if(organization === 'add_organization' | organization === 'None'){ + organization = null + } dropdown.attr('disabled', 'disabled') @@ -573,5 +593,6 @@ .graph-container display block height 250px + diff --git a/src/static/riot/datasets/management.tag b/src/static/riot/datasets/management.tag index eb6a257ed..7b782472c 100644 --- a/src/static/riot/datasets/management.tag +++ b/src/static/riot/datasets/management.tag @@ -305,6 +305,7 @@ .done(function () { self.update_datasets() toastr.success("Dataset deleted successfully!") + CODALAB.events.trigger('reload_quota_cleanup') }) .fail(function (response) { toastr.error(response.responseJSON['error']) @@ -320,6 +321,7 @@ self.update_datasets() toastr.success("Dataset deleted successfully!") self.marked_datasets = [] + CODALAB.events.trigger('reload_quota_cleanup') }) .fail(function (response) { for (e in response.responseJSON) { @@ -396,6 +398,7 @@ self.update_datasets() self.clear_form() $(self.refs.dataset_creation_modal).modal('hide') + CODALAB.events.trigger('reload_quota_cleanup') }) .fail(function (response) { if (response) { @@ -469,6 +472,9 @@ return(n.toFixed(1) + ' ' + units[i]); } + // Update datasets on unused datasets delete + CODALAB.events.on('reload_datasets', self.update_datasets) +