Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions scripts/refresh_contest_cache.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash
# Schedule daily contest cache refresh at 12:01 AM IST.
# Add to crontab with: crontab -e
# 1 0 * * * /path/to/Recursioncursor/backend/scripts/refresh_contest_cache.sh >> /var/log/contest_cache.log 2>&1

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
MANAGE_PY="$PROJECT_DIR/website/manage.py"

cd "$PROJECT_DIR/website"
python "$MANAGE_PY" refresh_contest_cache --force
Empty file.
Empty file.
10 changes: 10 additions & 0 deletions website/contest_notifications/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.urls import path

from .views import ContestNotificationsRefreshView, ContestNotificationsView

app_name = 'contest_notifications_api'

urlpatterns = [
path('', ContestNotificationsView.as_view(), name='contest_notifications'),
path('refresh/', ContestNotificationsRefreshView.as_view(), name='contest_notifications_refresh'),
]
57 changes: 57 additions & 0 deletions website/contest_notifications/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from contest_notifications.services import get_cached_contests, refresh_contest_cache


class ContestNotificationsView(APIView):
authentication_classes = ()
permission_classes = ()

def get(self, request):
platform = request.query_params.get('platform')

try:
payload = get_cached_contests()
except ValueError as exc:
return Response({'detail': str(exc)}, status=503)
except Exception as exc:
return Response(
{'detail': f'Failed to fetch contests: {exc}'},
status=502,
)

contests = payload.get('contests', [])
if platform:
contests = [
contest for contest in contests
if contest.get('platform') == platform.lower()
]

return Response({
'cached_at': payload.get('cached_at'),
'refresh_boundary': payload.get('refresh_boundary'),
'contests': contests,
})


class ContestNotificationsRefreshView(APIView):
authentication_classes = ()
permission_classes = ()

def post(self, request):
try:
payload = refresh_contest_cache(force=True)
except ValueError as exc:
return Response({'detail': str(exc)}, status=503)
except Exception as exc:
return Response(
{'detail': f'Failed to refresh contests: {exc}'},
status=502,
)

return Response({
'cached_at': payload.get('cached_at'),
'refresh_boundary': payload.get('refresh_boundary'),
'contest_count': len(payload.get('contests', [])),
})
6 changes: 6 additions & 0 deletions website/contest_notifications/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class ContestNotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'contest_notifications'
36 changes: 36 additions & 0 deletions website/contest_notifications/cache_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from datetime import datetime, timedelta

import pytz

CACHE_KEY = 'contest_notifications_data'
IST = pytz.timezone('Asia/Kolkata')


def get_last_refresh_boundary(now=None):
"""Return the most recent 12:01 AM IST boundary before *now*."""
if now is None:
now = datetime.now(IST)
elif now.tzinfo is None:
now = IST.localize(now)
else:
now = now.astimezone(IST)

boundary = now.replace(hour=0, minute=1, second=0, microsecond=0)
if now < boundary:
boundary -= timedelta(days=1)
return boundary


def is_cache_fresh(cached_at):
if not cached_at:
return False

if isinstance(cached_at, str):
cached_at = datetime.fromisoformat(cached_at.replace('Z', '+00:00'))

if cached_at.tzinfo is None:
cached_at = IST.localize(cached_at)
else:
cached_at = cached_at.astimezone(IST)

return cached_at >= get_last_refresh_boundary()
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.core.management.base import BaseCommand

from contest_notifications.services import refresh_contest_cache


class Command(BaseCommand):
help = 'Fetch upcoming contests from Clist and refresh the server cache.'

def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='Refresh even if the current cache is still valid.',
)

def handle(self, *args, **options):
payload = refresh_contest_cache(force=options['force'])
contest_count = len(payload.get('contests', []))
self.stdout.write(
self.style.SUCCESS(
f'Contest cache refreshed at {payload.get("cached_at")} '
f'with {contest_count} contests.'
)
)
199 changes: 199 additions & 0 deletions website/contest_notifications/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
from datetime import datetime, timezone

import pytz
import requests
from django.conf import settings
from django.core.cache import cache

from .cache_utils import CACHE_KEY, IST, get_last_refresh_boundary, is_cache_fresh

CLIST_BASE_URL = 'https://clist.by/api/v4/json/contest/'
PLATFORM_RESOURCES = {
'codeforces': 'codeforces.com',
'codechef': 'codechef.com',
'atcoder': 'atcoder.jp',
}
RESOURCE_ID_PLATFORMS = {
1: 'codeforces',
2: 'codechef',
93: 'atcoder',
}
RESOURCE_LABELS = {
'codeforces.com': 'Codeforces',
'codechef.com': 'CodeChef',
'atcoder.jp': 'AtCoder',
'codeforces': 'Codeforces',
'codechef': 'CodeChef',
'atcoder': 'AtCoder',
}


def _ordinal(day):
if 11 <= day % 100 <= 13:
suffix = 'th'
else:
suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th')
return f'{day}{suffix}'


def _format_start_time(dt):
dt = dt.astimezone(IST)
hour = dt.hour
minute = dt.minute
period = 'am' if hour < 12 else 'pm'
display_hour = hour % 12 or 12
return (
f'{_ordinal(dt.day)} {dt.strftime("%B")}, {dt.year} '
f'at {display_hour:02d}:{minute:02d} {period} IST'
)


def _format_duration(seconds):
if not seconds:
return 'unknown duration'

total_minutes = int(seconds) // 60
hours, minutes = divmod(total_minutes, 60)

if hours and minutes:
hour_label = 'hour' if hours == 1 else 'hours'
minute_label = 'minute' if minutes == 1 else 'minutes'
return f'{hours} {hour_label} {minutes} {minute_label}'

if hours:
return f'{hours} hour{"s" if hours != 1 else ""}'

return f'{minutes} minute{"s" if minutes != 1 else ""}'


def _build_notification_text(name, start_dt, duration_seconds, url):
start_text = _format_start_time(start_dt)
duration_text = _format_duration(duration_seconds)
return (
f'{name} will start on {start_text}.\n'
f'Contest duration is {duration_text}.\n\n'
f'Contest link: {url}\n'
f'Happy Coding! 😀'
)


def _parse_clist_datetime(value):
if not value:
return None

parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
if parsed.tzinfo is None:
# Clist returns UTC timestamps without a timezone suffix.
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(IST)


def _should_include_contest(platform, name):
normalized_name = (name or '').lower()

if platform == 'codeforces':
return True
if platform == 'codechef':
return 'starter' in normalized_name
if platform == 'atcoder':
return 'beginner' in normalized_name

return True


def _normalize_contest(item):
resource = item.get('resource', '')
resource_id = item.get('resource_id')

platform = RESOURCE_ID_PLATFORMS.get(resource_id)
if not platform:
platform = next(
(key for key, host in PLATFORM_RESOURCES.items() if host == resource),
resource.replace('.com', '').replace('.jp', ''),
)

start_dt = _parse_clist_datetime(item.get('start'))
end_dt = _parse_clist_datetime(item.get('end'))

duration_seconds = item.get('duration')
if not duration_seconds and start_dt and end_dt:
duration_seconds = int((end_dt - start_dt).total_seconds())

name = item.get('event') or item.get('name') or 'Upcoming Contest'
url = item.get('href') or item.get('url') or ''

return {
'platform': platform,
'platform_label': RESOURCE_LABELS.get(platform, RESOURCE_LABELS.get(resource, platform.title())),
'name': name,
'start_time': start_dt.isoformat() if start_dt else None,
'start_time_ist': _format_start_time(start_dt) if start_dt else None,
'duration_seconds': duration_seconds,
'duration_text': _format_duration(duration_seconds),
'url': url,
'notification_text': _build_notification_text(name, start_dt, duration_seconds, url)
if start_dt and url
else None,
}


def fetch_contests_from_clist():
username = settings.CLIST_USERNAME
api_key = settings.CLIST_API_KEY

if not username or not api_key:
raise ValueError('CLIST_USERNAME and CLIST_API_KEY must be configured.')

now = datetime.now(IST)
params = {
'username': username,
'api_key': api_key,
'resource_id__in': settings.CLIST_RESOURCE_IDS,
'upcoming': 'true',
'order_by': 'start',
'limit': 100,
}

response = requests.get(
CLIST_BASE_URL,
params=params,
timeout=30,
headers={'User-Agent': 'RecursionContestBot/1.0'},
)
response.raise_for_status()
payload = response.json()

contests = []
for item in payload.get('objects', []):
normalized = _normalize_contest(item)
if (
normalized['notification_text']
and _should_include_contest(normalized['platform'], normalized['name'])
):
contests.append(normalized)

contests.sort(key=lambda contest: contest['start_time'] or '')
return {
'cached_at': now.isoformat(),
'refresh_boundary': get_last_refresh_boundary(now).isoformat(),
'contests': contests,
}


def refresh_contest_cache(force=False):
cached_payload = cache.get(CACHE_KEY)

if not force and cached_payload and is_cache_fresh(cached_payload.get('cached_at')):
return cached_payload

payload = fetch_contests_from_clist()
cache.set(CACHE_KEY, payload, timeout=60 * 60 * 26)
return payload


def get_cached_contests():
cached_payload = cache.get(CACHE_KEY)
if cached_payload and is_cache_fresh(cached_payload.get('cached_at')):
return cached_payload

return refresh_contest_cache(force=True)
5 changes: 5 additions & 0 deletions website/website/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@

FRONTEND_BASE_URL = config('FRONTEND_BASE_URL', default='http://localhost:3000')

CLIST_USERNAME = config('CLIST_USERNAME', default='')
CLIST_API_KEY = config('CLIST_API_KEY', default='')
CLIST_RESOURCE_IDS = config('CLIST_RESOURCE_IDS', default='1,2,93')

# CORS_ALLOW_ALL_ORIGINS = True

CORS_ALLOWED_ORIGINS = [
Expand Down Expand Up @@ -83,6 +87,7 @@
'django_filters',
'django_prometheus',
'events_calendar',
'contest_notifications',
'corsheaders',
'rest_framework_simplejwt.token_blacklist',
'url_shortener',
Expand Down
4 changes: 4 additions & 0 deletions website/website/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@
path('api/events/', include('events_calendar.api.urls', namespace='events_api')),
path('api/team/', include('team.api.urls', namespace='team_api')),
path('api/getting_started/', include('getting_started.api.urls', namespace='getting_started_api')),

path('api/url/', include('url_shortener.urls', namespace='url_shortener')),

path('api/contests/', include('contest_notifications.api.urls', namespace='contest_notifications_api')),


# JWT
path('api/token/google/', LoginWithGoogleView.as_view(), name='token_for_google'),
path('api/token/', MyTokenObtainPairView.as_view(), name='token_obtain_pair'),
Expand Down