diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 699ea0d8d6..3874adee9e 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -12,6 +12,7 @@ from rest_framework.views import APIView from enrollment import api from enrollment.errors import CourseNotFoundError, CourseEnrollmentError, CourseModeNotFoundError from util.authentication import SessionAuthenticationAllowInactiveUser +from util.disable_rate_limit import can_disable_rate_limit class EnrollmentUserThrottle(UserRateThrottle): @@ -20,6 +21,7 @@ class EnrollmentUserThrottle(UserRateThrottle): rate = '50/second' +@can_disable_rate_limit class EnrollmentView(APIView): """ **Use Cases** @@ -101,6 +103,7 @@ class EnrollmentView(APIView): ) +@can_disable_rate_limit class EnrollmentCourseDetailView(APIView): """ **Use Cases** @@ -169,6 +172,7 @@ class EnrollmentCourseDetailView(APIView): ) +@can_disable_rate_limit class EnrollmentListView(APIView): """ **Use Cases** diff --git a/common/djangoapps/util/admin.py b/common/djangoapps/util/admin.py new file mode 100644 index 0000000000..de82409cc3 --- /dev/null +++ b/common/djangoapps/util/admin.py @@ -0,0 +1,7 @@ +"""Admin interface for the util app. """ + +from ratelimitbackend import admin +from util.models import RateLimitConfiguration + + +admin.site.register(RateLimitConfiguration) diff --git a/common/djangoapps/util/disable_rate_limit.py b/common/djangoapps/util/disable_rate_limit.py new file mode 100644 index 0000000000..a342953d88 --- /dev/null +++ b/common/djangoapps/util/disable_rate_limit.py @@ -0,0 +1,86 @@ +"""Utilities for disabling Django Rest Framework rate limiting. + +This is useful for performance tests in which we need to generate +a lot of traffic from a particular IP address. By default, +Django Rest Framework uses the IP address to throttle traffic +for users who are not authenticated. + +To disable rate limiting: + +1) Decorate the Django Rest Framework APIView with `@can_disable_rate_limit` +2) In Django's admin interface, set `RateLimitConfiguration.enabled` to False. + +Note: You should NEVER disable rate limiting in production. + +""" +from functools import wraps +import logging +from rest_framework.views import APIView +from util.models import RateLimitConfiguration + + +LOGGER = logging.getLogger(__name__) + + +def _check_throttles_decorator(func): + """Decorator for `APIView.check_throttles`. + + The decorated function will first check model-based config + to see if rate limiting is disabled; if so, it skips + the throttle check. Otherwise, it calls the original + function to enforce rate-limiting. + + Arguments: + func (function): The function to decorate. + + Returns: + The decorated function. + + """ + @wraps(func) + def _decorated(*args, **kwargs): + # Skip the throttle check entirely if we've disabled rate limiting. + # Otherwise, perform the checks (as usual) + if RateLimitConfiguration.current().enabled: + return func(*args, **kwargs) + else: + msg = "Rate limiting is disabled because `RateLimitConfiguration` is not enabled." + LOGGER.info(msg) + return + + return _decorated + + +def can_disable_rate_limit(clz): + """Class decorator that allows rate limiting to be disabled. + + Arguments: + clz (class): The APIView subclass to decorate. + + Returns: + class: the decorated class. + + Example Usage: + >>> from rest_framework.views import APIView + >>> @can_disable_rate_limit + >>> class MyApiView(APIView): + >>> pass + + """ + # No-op if the class isn't a Django Rest Framework view. + if not issubclass(clz, APIView): + msg = ( + u"{clz} is not a Django Rest Framework APIView subclass." + ).format(clz=clz) + LOGGER.warning(msg) + return clz + + # If we ARE explicitly disabling rate limiting, + # modify the class to always allow requests. + # Note that this overrides both rate limiting applied + # for the particular view, as well as global rate limits + # configured in Django settings. + if hasattr(clz, 'check_throttles'): + clz.check_throttles = _check_throttles_decorator(clz.check_throttles) + + return clz diff --git a/common/djangoapps/util/migrations/0001_initial.py b/common/djangoapps/util/migrations/0001_initial.py new file mode 100644 index 0000000000..baed557c2c --- /dev/null +++ b/common/djangoapps/util/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'RateLimitConfiguration' + db.create_table('util_ratelimitconfiguration', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('util', ['RateLimitConfiguration']) + + + def backwards(self, orm): + # Deleting model 'RateLimitConfiguration' + db.delete_table('util_ratelimitconfiguration') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'util.ratelimitconfiguration': { + 'Meta': {'object_name': 'RateLimitConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + } + } + + complete_apps = ['util'] \ No newline at end of file diff --git a/common/djangoapps/util/migrations/0002_default_rate_limit_config.py b/common/djangoapps/util/migrations/0002_default_rate_limit_config.py new file mode 100644 index 0000000000..f15ca5b558 --- /dev/null +++ b/common/djangoapps/util/migrations/0002_default_rate_limit_config.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + + +class Migration(DataMigration): + + def forwards(self, orm): + """Ensure that rate limiting is enabled by default. """ + orm['util.RateLimitConfiguration'].objects.create(enabled=True) + + def backwards(self, orm): + pass + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'util.ratelimitconfiguration': { + 'Meta': {'object_name': 'RateLimitConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + } + } + + complete_apps = ['util'] + symmetrical = True diff --git a/common/djangoapps/util/migrations/__init__.py b/common/djangoapps/util/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/util/models.py b/common/djangoapps/util/models.py index 6b20219993..a66628b426 100644 --- a/common/djangoapps/util/models.py +++ b/common/djangoapps/util/models.py @@ -1 +1,14 @@ -# Create your models here. +"""Models for the util app. """ +from config_models.models import ConfigurationModel + + +class RateLimitConfiguration(ConfigurationModel): + """Configuration flag to enable/disable rate limiting. + + Applies to Django Rest Framework views. + + This is useful for disabling rate limiting for performance tests. + When enabled, it will disable rate limiting on any view decorated + with the `can_disable_rate_limit` class decorator. + """ + pass diff --git a/common/djangoapps/util/tests/test_disable_rate_limit.py b/common/djangoapps/util/tests/test_disable_rate_limit.py new file mode 100644 index 0000000000..5b9d8f9f12 --- /dev/null +++ b/common/djangoapps/util/tests/test_disable_rate_limit.py @@ -0,0 +1,55 @@ +"""Tests for disabling rate limiting. """ +import unittest +from django.test import TestCase +from django.core.cache import cache +from django.conf import settings +import mock + +from rest_framework.views import APIView +from rest_framework.throttling import BaseThrottle +from rest_framework.exceptions import Throttled + +from util.disable_rate_limit import can_disable_rate_limit +from util.models import RateLimitConfiguration + + +class FakeThrottle(BaseThrottle): + def allow_request(self, request, view): + return False + + +@can_disable_rate_limit +class FakeApiView(APIView): + authentication_classes = [] + permission_classes = [] + throttle_classes = [FakeThrottle] + + +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class DisableRateLimitTest(TestCase): + """Check that we can disable rate limiting for perf testing. """ + + def setUp(self): + cache.clear() + self.view = FakeApiView() + + def test_enable_rate_limit(self): + # Enable rate limiting using model-based config + RateLimitConfiguration.objects.create(enabled=True) + + # By default, should enforce rate limiting + # Since our fake throttle always rejects requests, + # we should expect the request to be rejected. + request = mock.Mock() + with self.assertRaises(Throttled): + self.view.check_throttles(request) + + def test_disable_rate_limit(self): + # Disable rate limiting using model-based config + RateLimitConfiguration.objects.create(enabled=False) + + # With rate-limiting disabled, the request + # should get through. The `check_throttles()` call + # should return without raising an exception. + request = mock.Mock() + self.view.check_throttles(request)