diff --git a/.env_sample b/.env_sample index a60000460..26c9337bd 100644 --- a/.env_sample +++ b/.env_sample @@ -10,6 +10,7 @@ DB_PORT=5432 DJANGO_SETTINGS_MODULE=settings.develop ALLOWED_HOSTS=localhost,example.com SUBMISSIONS_API_URL=http://django:8000/api +MAX_EXECUTION_TIME_LIMIT=600 # time limit for the default queue (in seconds) # Local domain definition DOMAIN_NAME=localhost:80 diff --git a/README.md b/README.md index 2b3308d0f..8c6da464e 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,12 @@ $ docker-compose exec django ./manage.py generate_data $ docker-compose exec django ./manage.py collectstatic --noinput ``` -You can now login as username "admin" with password "admin" at http://localhost:8000 +You can now login as username "admin" with password "admin" at http://localhost/ If you ever need to reset the database, use the script `./reset_db.sh` +For more information about installation, checkout [Codabench Basic Installation Guide](https://github.com/codalab/codabench/wiki/Codabench-Installation) and [How to Deploy Server](https://github.com/codalab/codabench/wiki/How-to-deploy-Codabench-on-your-server). + ## License diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 491b84e5f..24e7d25bc 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -507,12 +507,25 @@ async def _run_container_engine_cmd(self, engine_cmd, kind): websocket = await websockets.connect(self.websocket_url) websocket_errors = (socket.gaierror, websockets.WebSocketException, websockets.ConnectionClosedError, ConnectionRefusedError) + # Function to read a line, if the line is larger than the buffer size we will + # return the buffer so we can continue reading until we get a newline, rather + # than getting a LimitOverrunError + async def _readline_or_chunk(stream): + try: + return await stream.readuntil(b"\n") + except asyncio.exceptions.IncompleteReadError as e: + # Just return what has been read so far + return e.partial + except asyncio.exceptions.LimitOverrunError as e: + # If we get a LimitOverrunError, we will return the buffer so we can continue reading + return await stream.read(e.consumed) + while any(v["continue"] for k, v in self.logs[kind].items() if k in ['stdout', 'stderr']): try: logs = [self.logs[kind][key] for key in ('stdout', 'stderr')] for value in logs: try: - out = await asyncio.wait_for(value["stream"].readline(), timeout=.1) + out = await asyncio.wait_for(_readline_or_chunk(value["stream"]), timeout=.1) if out: value["data"] += out print("WS: " + str(out)) diff --git a/docker-compose.yml b/docker-compose.yml index 0b5c2c6ee..08e7a5c84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -231,4 +231,4 @@ services: logging: options: max-size: "20k" - max-file: "10" + max-file: "10" \ No newline at end of file diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 9b622b3dc..41cd51341 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -254,7 +254,10 @@ class Meta: 'registration_auto_approve', 'queue', 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', 'auto_run_submissions', + 'can_participants_make_submissions_public', 'make_programs_available', 'make_input_data_available', 'docker_image', @@ -373,7 +376,10 @@ class Meta: 'submission_count', 'queue', 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', 'auto_run_submissions', + 'can_participants_make_submissions_public', 'make_programs_available', 'make_input_data_available', 'docker_image', diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index 33fe9241e..05191e8e3 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -42,6 +42,7 @@ class SubmissionSerializer(serializers.ModelSerializer): task = TaskSerializer() created_when = serializers.DateTimeField(format="%Y-%m-%d %H:%M") auto_run = serializers.SerializerMethodField(read_only=True) + can_make_submissions_public = serializers.SerializerMethodField(read_only=True) class Meta: model = Submission @@ -67,7 +68,8 @@ class Meta: 'leaderboard', 'on_leaderboard', 'task', - 'auto_run' + 'auto_run', + 'can_make_submissions_public', ) read_only_fields = ( 'pk', @@ -85,6 +87,10 @@ def get_auto_run(self, instance): # returns this submission's competition auto_run_submissions Flag return instance.phase.competition.auto_run_submissions + def get_can_make_submissions_public(self, instance): + # returns this submission's competition can_participants_make_submissions_public Flag + return instance.phase.competition.can_participants_make_submissions_public + class SubmissionLeaderBoardSerializer(serializers.ModelSerializer): scores = SubmissionScoreSerializer(many=True) diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 989f668f4..59ebc216c 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -111,7 +111,7 @@ def test_manual_migration_makes_submissions_from_one_phase_in_another(self): # make 5 submissions in phase 1 for _ in range(5): - SubmissionFactory(owner=self.creator, phase=self.phase_1, status=Submission.FINISHED) + SubmissionFactory(owner=self.creator, phase=self.phase_1, status=Submission.FINISHED, leaderboard=self.leaderboard) assert self.phase_1.submissions.count() == 5 assert self.phase_2.submissions.count() == 0 @@ -130,7 +130,7 @@ def test_manual_migration_makes_submissions_out_of_only_parents_not_children(sel self.client.login(username='creator', password='creator') # make 1 submission with 4 children - parent = SubmissionFactory(owner=self.creator, phase=self.phase_1, has_children=True, status=Submission.FINISHED) + parent = SubmissionFactory(owner=self.creator, phase=self.phase_1, has_children=True, status=Submission.FINISHED, leaderboard=self.leaderboard) for _ in range(4): # Make a submission _and_ new Task for phase 2 self.phase_2.tasks.add(TaskFactory()) diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index d0c0defb7..fccaeefd4 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -352,15 +352,15 @@ def get_detail_result(self, request, pk): ) else: return Response({ - "error_msg": "Visualizations are disabled"}, + "error_msg": "Detailed results are disable for this competition!"}, status=status.HTTP_404_NOT_FOUND ) @action(detail=True, methods=('GET',)) def toggle_public(self, request, pk): submission = super().get_object() - if not self.has_admin_permission(request.user, submission): - raise PermissionDenied(f'You do not have permission to publish this submissions') + if not submission.phase.competition.can_participants_make_submissions_public: + raise PermissionDenied("You do not have permission to make this submissions public/private") is_public = not submission.is_public submission.data.is_public = is_public submission.data.save(send=False) diff --git a/src/apps/chahub/tests/test_chahub_mixin.py b/src/apps/chahub/tests/test_chahub_mixin.py index 75565c196..77aa3532a 100644 --- a/src/apps/chahub/tests/test_chahub_mixin.py +++ b/src/apps/chahub/tests/test_chahub_mixin.py @@ -21,6 +21,7 @@ def setUp(self): participant=self.participant, status='Finished', is_public=True, + leaderboard=None ) def test_submission_save_sends_to_chahub(self): diff --git a/src/apps/competitions/migrations/0047_competition_can_participants_make_submissions_public.py b/src/apps/competitions/migrations/0047_competition_can_participants_make_submissions_public.py new file mode 100644 index 000000000..2b750fa02 --- /dev/null +++ b/src/apps/competitions/migrations/0047_competition_can_participants_make_submissions_public.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2024-03-28 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0046_merge_20240222_1916'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='can_participants_make_submissions_public', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/migrations/0048_auto_20240401_1646.py b/src/apps/competitions/migrations/0048_auto_20240401_1646.py new file mode 100644 index 000000000..3ed2ad446 --- /dev/null +++ b/src/apps/competitions/migrations/0048_auto_20240401_1646.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.17 on 2024-04-01 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0047_competition_can_participants_make_submissions_public'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='show_detailed_results_in_leaderboard', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='competition', + name='show_detailed_results_in_submission_panel', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index 03d8b65fc..48c26bec4 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -12,7 +12,7 @@ from django.urls import reverse from django.utils.timezone import now -from celery_config import app +from celery_config import app, app_for_vhost from chahub.models import ChaHubSaveMixin from leaderboards.models import SubmissionScore from profiles.models import User, Organization @@ -49,6 +49,10 @@ class Competition(ChaHubSaveMixin, models.Model): description = models.TextField(null=True, blank=True) docker_image = models.CharField(max_length=128, default="codalab/codalab-legacy:py37") enable_detailed_results = models.BooleanField(default=False) + # If true, show detailed results in submission panel + show_detailed_results_in_submission_panel = models.BooleanField(default=True) + # If true, show detailed results in leaderboard + show_detailed_results_in_leaderboard = models.BooleanField(default=True) make_programs_available = models.BooleanField(default=False) make_input_data_available = models.BooleanField(default=False) @@ -69,6 +73,9 @@ class Competition(ChaHubSaveMixin, models.Model): # if false, submissions run will be intiiated by organizer auto_run_submissions = models.BooleanField(default=True) + # If true, participants see the make their submissions public + can_participants_make_submissions_public = models.BooleanField(default=True) + def __str__(self): return f"competition-{self.title}-{self.pk}-{self.competition_type}" @@ -121,10 +128,12 @@ def apply_phase_migration(self, current_phase, next_phase, force_migration=False self.is_migrating = True self.save() + # Get submissions of current phase with finished status and which are on leaderboard submissions = Submission.objects.filter( phase=current_phase, is_migrated=False, parent__isnull=True, + leaderboard__isnull=False, status=Submission.FINISHED ) @@ -644,7 +653,12 @@ def cancel(self, status=CANCELLED): if self.has_children: for sub in self.children.all(): sub.cancel(status=status) - app.control.revoke(self.celery_task_id, terminate=True) + celery_app = app + # If a custom queue is set, we need to fetch the appropriate celery app + if self.phase.competition.queue: + celery_app = app_for_vhost(str(self.phase.competition.queue.vhost)) + + celery_app.control.revoke(self.celery_task_id, terminate=True) self.status = status self.save() return True diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 87ad0d316..facc8041b 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -99,6 +99,7 @@ 'computation_indexes', 'hidden', ] +MAX_EXECUTION_TIME_LIMIT = int(os.environ.get('MAX_EXECUTION_TIME_LIMIT', 600)) # time limit of the default queue def _send_to_compute_worker(submission, is_scoring): @@ -107,7 +108,7 @@ def _send_to_compute_worker(submission, is_scoring): "submissions_api_url": settings.SUBMISSIONS_API_URL, "secret": submission.secret, "docker_image": submission.phase.competition.docker_image, - "execution_time_limit": submission.phase.execution_time_limit, + "execution_time_limit": min(MAX_EXECUTION_TIME_LIMIT, submission.phase.execution_time_limit), "id": submission.pk, "is_scoring": is_scoring, } @@ -185,8 +186,9 @@ def _send_to_compute_worker(submission, is_scoring): time_padding = 60 * 20 # 20 minutes time_limit = submission.phase.execution_time_limit + time_padding - if submission.phase.competition.queue: + if submission.phase.competition.queue: # if the competition is running on a custom queue, not the default queue submission.queue_name = submission.phase.competition.queue.name or '' + run_args['execution_time_limit'] = submission.phase.execution_time_limit # use the competition time limit submission.save() # Send to special queue? Using `celery_app` var name here since we'd be overriding the imported `app` diff --git a/src/apps/competitions/tests/test_phase_migration.py b/src/apps/competitions/tests/test_phase_migration.py index 404c0fee5..f1b6e77a8 100644 --- a/src/apps/competitions/tests/test_phase_migration.py +++ b/src/apps/competitions/tests/test_phase_migration.py @@ -7,7 +7,7 @@ from competitions.models import Submission, Competition, Phase from competitions.tasks import do_phase_migrations from factories import UserFactory, CompetitionFactory, PhaseFactory, SubmissionFactory, CompetitionParticipantFactory, \ - TaskFactory + TaskFactory, LeaderboardFactory twenty_minutes_ago = now() - timedelta(hours=0, minutes=20) twenty_five_minutes_ago = now() - timedelta(hours=0, minutes=25) @@ -22,6 +22,7 @@ def setUp(self): self.competition = CompetitionFactory(created_by=self.owner, title="Competition One") self.competition_participant = CompetitionParticipantFactory(user=self.normal_user, competition=self.competition) + self.leaderboard = LeaderboardFactory() self.phase1 = PhaseFactory( competition=self.competition, auto_migrate_to_this_phase=False, @@ -59,6 +60,7 @@ def make_submission(self, **kwargs): kwargs.setdefault('participant', self.competition_participant) kwargs.setdefault('phase', self.phase1) kwargs.setdefault('status', Submission.FINISHED) + kwargs.setdefault('leaderboard', self.leaderboard) sub = SubmissionFactory(**kwargs) return sub diff --git a/src/apps/competitions/unpackers/v1.py b/src/apps/competitions/unpackers/v1.py index 91496ddca..dc476a5c2 100644 --- a/src/apps/competitions/unpackers/v1.py +++ b/src/apps/competitions/unpackers/v1.py @@ -23,6 +23,8 @@ def __init__(self, *args, **kwargs): "description": self.competition_yaml.get("description", ""), "docker_image": docker_image, "enable_detailed_results": self.competition_yaml.get('enable_detailed_results', False), + "show_detailed_results_in_submission_panel": self.competition_yaml.get('show_detailed_results_in_submission_panel', True), + "show_detailed_results_in_leaderboard": self.competition_yaml.get('show_detailed_results_in_leaderboard', True), "auto_run_submissions": self.competition_yaml.get('auto_run_submissions', True), "make_programs_available": self.competition_yaml.get('make_programs_available', False), "make_input_data_available": self.competition_yaml.get('make_input_data_available', False), diff --git a/src/apps/competitions/unpackers/v2.py b/src/apps/competitions/unpackers/v2.py index 8de2a42ad..b825e9ee2 100644 --- a/src/apps/competitions/unpackers/v2.py +++ b/src/apps/competitions/unpackers/v2.py @@ -14,7 +14,10 @@ def __init__(self, *args, **kwargs): "registration_auto_approve": self.competition_yaml.get('registration_auto_approve', False), "docker_image": self.competition_yaml.get('docker_image', 'codalab/codalab-legacy:py37'), "enable_detailed_results": self.competition_yaml.get('enable_detailed_results', False), + "show_detailed_results_in_submission_panel": self.competition_yaml.get('show_detailed_results_in_submission_panel', True), + "show_detailed_results_in_leaderboard": self.competition_yaml.get('show_detailed_results_in_leaderboard', True), "auto_run_submissions": self.competition_yaml.get('auto_run_submissions', True), + "can_participants_make_submissions_public": self.competition_yaml.get('can_participants_make_submissions_public', True), "make_programs_available": self.competition_yaml.get('make_programs_available', False), "make_input_data_available": self.competition_yaml.get('make_input_data_available', False), "description": self.competition_yaml.get("description", ""), diff --git a/src/apps/oidc_configurations/__init__.py b/src/apps/oidc_configurations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/oidc_configurations/admin.py b/src/apps/oidc_configurations/admin.py new file mode 100644 index 000000000..5ea6e683f --- /dev/null +++ b/src/apps/oidc_configurations/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from .models import Auth_Organization + +admin.site.register(Auth_Organization) + +# Register your models here. diff --git a/src/apps/oidc_configurations/apps.py b/src/apps/oidc_configurations/apps.py new file mode 100644 index 000000000..3d757062b --- /dev/null +++ b/src/apps/oidc_configurations/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OidcConfigurationsConfig(AppConfig): + name = 'oidc_configurations' diff --git a/src/apps/oidc_configurations/migrations/0001_initial.py b/src/apps/oidc_configurations/migrations/0001_initial.py new file mode 100644 index 000000000..085e64983 --- /dev/null +++ b/src/apps/oidc_configurations/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.17 on 2024-03-04 06:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Auth_Organization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('client_id', models.CharField(max_length=255)), + ('client_secret', models.CharField(max_length=255)), + ('authorization_url', models.CharField(max_length=255)), + ('token_url', models.CharField(max_length=255)), + ('user_info_url', models.CharField(max_length=255)), + ('redirect_url', models.CharField(max_length=255)), + ('button_bg_color', models.CharField(default='#2C3E4C', max_length=20)), + ('button_text_color', models.CharField(default='#FFFFFF', max_length=20)), + ], + ), + ] diff --git a/src/apps/oidc_configurations/migrations/__init__.py b/src/apps/oidc_configurations/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/oidc_configurations/models.py b/src/apps/oidc_configurations/models.py new file mode 100644 index 000000000..9e2b0c66c --- /dev/null +++ b/src/apps/oidc_configurations/models.py @@ -0,0 +1,14 @@ +# oidc_configurations/models.py +from django.db import models + + +class Auth_Organization(models.Model): + name = models.CharField(max_length=255) + client_id = models.CharField(max_length=255) + client_secret = models.CharField(max_length=255) + authorization_url = models.CharField(max_length=255) + token_url = models.CharField(max_length=255) + user_info_url = models.CharField(max_length=255) + redirect_url = models.CharField(max_length=255) + button_bg_color = models.CharField(max_length=20, default='#2C3E4C') + button_text_color = models.CharField(max_length=20, default='#FFFFFF') diff --git a/src/apps/oidc_configurations/urls.py b/src/apps/oidc_configurations/urls.py new file mode 100644 index 000000000..7bfae4f99 --- /dev/null +++ b/src/apps/oidc_configurations/urls.py @@ -0,0 +1,10 @@ +# oidc_configurations/urls.py +from django.urls import path +from .views import organization_oidc_login, oidc_complete + +app_name = 'oidc_configurations' + +urlpatterns = [ + path('organization_oidc_login/', organization_oidc_login, name='organization_oidc_login'), + path('complete//', oidc_complete, name='oidc_complete'), +] diff --git a/src/apps/oidc_configurations/views.py b/src/apps/oidc_configurations/views.py new file mode 100644 index 000000000..6b04be8ff --- /dev/null +++ b/src/apps/oidc_configurations/views.py @@ -0,0 +1,203 @@ +# oidc_configurations/views.py +import base64 +import requests +from django.shortcuts import render, redirect, get_object_or_404 +from .models import Auth_Organization +from django.contrib.auth import get_user_model, login +import re + +User = get_user_model() + +BACKEND = 'django.contrib.auth.backends.ModelBackend' + + +def organization_oidc_login(request): + # Check if this is a post request and it contains organization_oauth2_login + if request.method == 'POST' and 'organization_oidc_login' in request.POST: + # Get auth organization id from the request + auth_organization_id = request.POST.get('organization_oidc_login') + + # Get auth organization using its id + organization = get_object_or_404(Auth_Organization, pk=auth_organization_id) + + if organization: + # Create a redirect url consisiting of + # - authorization_url + # - client_id + # - response_type + # - redirect_uri + oidc_auth_url = ( + f"{organization.authorization_url}?" + f"client_id={organization.client_id}&" + "response_type=code&" + "scope=openid profile email&" + f"redirect_uri={organization.redirect_url}" + ) + + # Redirect the user to the OIDC provider's authorization URL + return redirect(oidc_auth_url) + + # Handle other cases or render a different template if needed + return render(request, 'registration/login.html') + + +def oidc_complete(request, auth_organization_id): + + # create empty context + context = {} + + # Get error or authorization code from the query string + error = request.GET.get('error', None) + error_description = request.GET.get('error_description', None) + authorization_code = request.GET.get('code', None) + + if error: + context["error"] = error + + if error_description: + context["error_description"] = error_description + + # Token exhange process + if authorization_code: + + try: + # STEP 1: Get auth organization using its id + organization = get_object_or_404(Auth_Organization, pk=auth_organization_id) + + if organization: + + # STEP 2: Get access token + access_token, token_error = get_access_token(organization, authorization_code) + + if token_error: + context["error"] = token_error + else: + # STEP 3: Get user info + user_info, user_info_error = get_user_info(organization, access_token) + if user_info_error: + context["error"] = user_info_error + else: + + # get email and nickname (username) of the user + user_email = user_info.get("email", None) + user_nickname = user_info.get("nickname", None) + if user_email: + # get user with this email + user = get_user_by_email(user_email) + # STEP 4: Check if user exists and user is created using oidc and oidc orgnaization matches this one + if user: + login(request, user, backend=BACKEND) + # Redirect the user home page + return redirect('pages:home') + else: + return register_and_authenticate_user(request, user_email, user_nickname, organization) + + else: + context["error"] = "Unable to extract email from user info! Please contact platform" + else: + context["error"] = "Invalid Organization ID!" + except Exception as e: + context["error"] = f"{e}" + + return render(request, 'oidc/oidc_complete.html', context) + + +def get_access_token(organization, authorization_code): + + token_url = organization.token_url + client_id = organization.client_id + client_secret = organization.client_secret + redirect_url = organization.redirect_url + + auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("utf-8") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {auth_header}", + } + data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": redirect_url, + } + + try: + response = requests.request("POST", token_url, data=data, headers=headers) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) + token_data = response.json() + access_token = token_data.get('access_token') + return access_token, None + except requests.exceptions.RequestException as e: + print(f"Error during token request: {e}") + return None, e + except Exception as e: + print(f"Error parsing token response: {e}") + return None, e + + +def get_user_info(organization, access_token): + + user_info_url = organization.user_info_url + + headers = { + 'Authorization': f'Bearer {access_token}', + } + + response = requests.get(user_info_url, headers=headers) + + try: + user_info = response.json() + return user_info, None + except Exception as e: + return None, e + + +def register_and_authenticate_user(request, user_email, user_nickname, organization): + + if not user_nickname: + username = re.sub(r'[^a-zA-Z0-9]', '', user_email.split('@')[0]) + else: + username = user_nickname + + # Ensure the username is unique + username = create_unique_username(username) + + # Create a new user + user = User.objects.create( + username=username, + email=user_email, + is_created_using_oidc=True, + oidc_organization=organization, + ) + + if user: + # login user + login(request, user, backend=BACKEND) + # Redirect to the home page + return redirect('pages:home') + + else: + # Handle authentication failure i.e. go back to login + return redirect('accounts:login') + + +def create_unique_username(username): + # Check if the username already exists + if User.objects.filter(username=username).exists(): + # If the username already exists, modify it to make it unique + suffix = 1 + new_username = f"{username}_{suffix}" + while User.objects.filter(username=new_username).exists(): + suffix += 1 + new_username = f"{username}_{suffix}" + return new_username + else: + # If the username doesn't exist, use it as is + return username + + +def get_user_by_email(email): + try: + user = User.objects.get(email=email) + return user + except User.DoesNotExist: + return None diff --git a/src/apps/profiles/migrations/0013_auto_20240304_0616.py b/src/apps/profiles/migrations/0013_auto_20240304_0616.py new file mode 100644 index 000000000..121ca477c --- /dev/null +++ b/src/apps/profiles/migrations/0013_auto_20240304_0616.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.17 on 2024-03-04 06:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_configurations', '0001_initial'), + ('profiles', '0012_user_quota'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_created_using_oidc', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='oidc_organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorized_users', to='oidc_configurations.Auth_Organization'), + ), + ] diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index 518230f92..b150e54e4 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -16,6 +16,7 @@ When, DecimalField, ) +from oidc_configurations.models import Auth_Organization PROFILE_DATA_BLACKLIST = [ 'password', @@ -72,6 +73,10 @@ class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) quota = models.BigIntegerField(default=settings.DEFAULT_USER_QUOTA, null=False) + # Fields for OIDC authentication + is_created_using_oidc = models.BooleanField(default=False) + oidc_organization = models.ForeignKey(Auth_Organization, null=True, blank=True, on_delete=models.SET_NULL, related_name="authorized_users") + # Notifications organizer_direct_message_updates = models.BooleanField(default=True) allow_forum_notifications = models.BooleanField(default=True) diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 3b6b22169..a6a21cb64 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -21,6 +21,7 @@ UserNotificationSerializer from .forms import SignUpForm, LoginForm from .models import User, Organization, Membership +from oidc_configurations.models import Auth_Organization from .tokens import account_activation_token @@ -178,6 +179,11 @@ def log_in(request): else: context['form'] = form + # Fetch auth_organizations from the database + auth_organizations = Auth_Organization.objects.all() + if auth_organizations: + context['auth_organizations'] = auth_organizations + if not context.get('form'): context['form'] = LoginForm() return render(request, 'registration/login.html', context) diff --git a/src/celery_config.py b/src/celery_config.py index 76c52a7a4..7ef8f818e 100644 --- a/src/celery_config.py +++ b/src/celery_config.py @@ -1,5 +1,8 @@ from celery import Celery from kombu import Queue, Exchange +from django.conf import settings +import urllib.parse +import copy app = Celery() @@ -11,3 +14,25 @@ # Mostly defining queue here so we can set x-max-priority Queue('compute-worker', Exchange('compute-worker'), routing_key='compute-worker', queue_arguments={'x-max-priority': 10}), ] + +_vhost_apps = {} + + +def app_for_vhost(vhost): + # Function to get the app for a vhost + if vhost not in _vhost_apps: + # Take the CELERY_BROKER_URL and replace the vhost with the vhhost for this queue + broker_url = settings.CELERY_BROKER_URL + # This is require to work around https://bugs.python.org/issue18828 + scheme = urllib.parse.urlparse(broker_url).scheme + urllib.parse.uses_relative.append(scheme) + urllib.parse.uses_netloc.append(scheme) + broker_url = urllib.parse.urljoin(broker_url, vhost) + vhost_app = Celery() + # Copy the settings so we can modify the broker url to include the vhost + django_settings = copy.copy(settings) + django_settings.CELERY_BROKER_URL = broker_url + vhost_app.config_from_object(django_settings, namespace='CELERY') + vhost_app.task_queues = app.conf.task_queues + _vhost_apps[vhost] = vhost_app + return _vhost_apps[vhost] diff --git a/src/factories.py b/src/factories.py index d0c47716c..c76384f5c 100644 --- a/src/factories.py +++ b/src/factories.py @@ -154,6 +154,14 @@ class Meta: task = factory.SubFactory(TaskFactory) +class LeaderboardFactory(DjangoModelFactory): + class Meta: + model = Leaderboard + + title = factory.Faker('word') + key = factory.Faker('word') + + class SubmissionFactory(DjangoModelFactory): class Meta: model = Submission @@ -161,6 +169,7 @@ class Meta: owner = factory.SubFactory(UserFactory) phase = factory.SubFactory(PhaseFactory) name = factory.Sequence(lambda n: f'Submission {n}') + leaderboard = factory.SubFactory(LeaderboardFactory) created_when = factory.Faker('date_time_between', start_date='-5y', end_date='now', tzinfo=UTC) data = factory.SubFactory( @@ -182,14 +191,6 @@ class Meta: status = factory.LazyAttribute(lambda n: random.choice(['unknown', 'denied', 'approved', 'pending'])) -class LeaderboardFactory(DjangoModelFactory): - class Meta: - model = Leaderboard - - title = factory.Faker('word') - key = factory.Faker('word') - - class ColumnFactory(DjangoModelFactory): class Meta: model = Column diff --git a/src/settings/base.py b/src/settings/base.py index b8d133a05..9114e0687 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -60,6 +60,7 @@ 'health', 'forums', 'announcements', + 'oidc_configurations', ) INSTALLED_APPS = THIRD_PARTY_APPS + OUR_APPS @@ -330,6 +331,7 @@ } SUBMISSIONS_API_URL = os.environ.get('SUBMISSIONS_API_URL', "http://django/api") +MAX_EXECUTION_TIME_LIMIT = os.environ.get('MAX_EXECUTION_TIME_LIMIT', "600") # time limit of the default queue # ============================================================================= # Storage diff --git a/src/static/riot/competitions/detail/_detailed_results.tag b/src/static/riot/competitions/detail/_detailed_results.tag index 0136e6689..bb2f87e30 100644 --- a/src/static/riot/competitions/detail/_detailed_results.tag +++ b/src/static/riot/competitions/detail/_detailed_results.tag @@ -1,5 +1,4 @@ -

Detailed Results