├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── circle.yml ├── scheduler ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_add_timeout.py │ ├── 0003_remove_queue_choices.py │ ├── 0004_add_cron_jobs.py │ ├── 0005_added_result_ttl.py │ └── __init__.py ├── models.py ├── tests.py └── views.py ├── setup.cfg ├── setup.py └── testproject19 ├── manage.py └── testproject19 ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | # PyCharm 65 | .idea 66 | 67 | # SQLite 68 | *.sqlite3 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 iStrategyLabs, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include README.md 3 | recursive-include scheduler *.py 4 | exclude testproject19 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django RQ Scheduler 2 | 3 | A database backed job scheduler for Django RQ. 4 | 5 | ## Requirements 6 | 7 | Currently, when you pip install Django RQ Scheduler the following packages are also installed. 8 | 9 | * django >= 1.9 10 | * django-model-utils >= 2.4 11 | * django-rq >= 0.9.3 (Django RQ requires RQ >= 0.5.5) 12 | * rq-scheduler >= 0.6.0 13 | * pytz >= 2015.7 14 | * croniter >= 0.3.24 15 | 16 | Testing also requires: 17 | 18 | * factory_boy >= 2.6.1 19 | * psycopg2 >= 2.6.1 20 | 21 | 22 | ## Usage 23 | 24 | ### Install 25 | 26 | Use pip to install: 27 | 28 | ``` 29 | pip install django-rq-scheduler 30 | ``` 31 | 32 | 33 | ### Update Django Settings 34 | 35 | 1. In `settings.py`, add `django_rq` and `scheduler` to `INSTALLED_APPS`: 36 | 37 | ``` 38 | 39 | INSTALLED_APPS = [ 40 | ... 41 | 'django_rq', 42 | 'scheduler', 43 | ... 44 | ] 45 | 46 | 47 | ``` 48 | 49 | 2. Configure Django RQ. See https://github.com/ui/django-rq#installation 50 | 51 | 52 | ### Migrate 53 | 54 | The last step is migrate the database: 55 | 56 | ``` 57 | ./manage.py migrate 58 | ``` 59 | 60 | ## Creating a Job 61 | 62 | See http://python-rq.org/docs/jobs/ or https://github.com/ui/django-rq#job-decorator 63 | 64 | An example: 65 | 66 | **myapp.jobs.py** 67 | 68 | ``` 69 | @job 70 | def count(): 71 | return 1 + 1 72 | ``` 73 | 74 | ## Scheduling a Job 75 | 76 | ### Scheduled Job 77 | 78 | 1. Sign into the Django Admin site, http://localhost:8000/admin/ and locate the **Django RQ Scheduler** section. 79 | 80 | 2. Click on the **Add** link for Scheduled Job. 81 | 82 | 3. Enter a unique name for the job in the **Name** field. 83 | 84 | 4. In the **Callable** field, enter a Python dot notation path to the method that defines the job. For the example above, that would be `myapp.jobs.count` 85 | 86 | 5. Choose your **Queue**. Side Note: The queues listed are defined in the Django Settings. 87 | 88 | 6. Enter the time the job is to be executed in the **Scheduled time** field. Side Note: Enter the date via the browser's local timezone, the time will automatically convert UTC. 89 | 90 | 7. Click the **Save** button to schedule the job. 91 | 92 | ### Repeatable Job 93 | 94 | 1. Sign into the Django Admin site, http://localhost:8000/admin/ and locate the **Django RQ Scheduler** section. 95 | 96 | 2. Click on the **Add** link for Repeatable Job 97 | 98 | 3. Enter a unique name for the job in the **Name** field. 99 | 100 | 4. In the **Callable** field, enter a Python dot notation path to the method that defines the job. For the example above, that would be `myapp.jobs.count` 101 | 102 | 5. Choose your **Queue**. Side Note: The queues listed are defined in the Django Settings. 103 | 104 | 6. Enter the time the first job is to be executed in the **Scheduled time** field. Side Note: Enter the date via the browser's local timezone, the time will automatically convert UTC. 105 | 106 | 7. Enter an **Interval**, and choose the **Interval unit**. This will calculate the time before the function is called again. 107 | 108 | 8. In the **Repeat** field, enter the number of time the job is to be ran. Leaving the field empty, means the job will be scheduled to run forever. 109 | 110 | 9. Click the **Save** button to schedule the job. 111 | 112 | 113 | ## Reporting issues or Features 114 | 115 | Please report issues via [GitHub Issues](https://github.com/istrategylabs/django-rq-scheduler/issues) . 116 | 117 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | python: 3 | version: 2.7.9 4 | services: 5 | - postgresql 6 | - redis 7 | environment: 8 | DB_NAME: circle_ci 9 | DB_USER: ubuntu 10 | dependencies: 11 | override: 12 | - pip install -U pip setuptools 13 | - pip install -e .[test] 14 | test: 15 | override: 16 | - ./testproject19/manage.py test 17 | -------------------------------------------------------------------------------- /scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.1.3' 2 | 3 | default_app_config = 'scheduler.apps.SchedulerConfig' 4 | -------------------------------------------------------------------------------- /scheduler/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf import settings 4 | from django.contrib import admin 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | from scheduler.models import CronJob, RepeatableJob, ScheduledJob 8 | 9 | 10 | QUEUES = [(key, key) for key in settings.RQ_QUEUES.keys()] 11 | 12 | 13 | class QueueMixin(object): 14 | actions = ['delete_model'] 15 | 16 | def get_actions(self, request): 17 | actions = super(QueueMixin, self).get_actions(request) 18 | del actions['delete_selected'] 19 | return actions 20 | 21 | def get_form(self, request, obj=None, **kwargs): 22 | queue_field = self.model._meta.get_field('queue') 23 | queue_field.choices = QUEUES 24 | return super(QueueMixin, self).get_form(request, obj, **kwargs) 25 | 26 | def delete_model(self, request, obj): 27 | if hasattr(obj, 'all'): 28 | for o in obj.all(): 29 | o.delete() 30 | else: 31 | obj.delete() 32 | delete_model.short_description = _("Delete selected %(verbose_name_plural)s") 33 | 34 | 35 | @admin.register(ScheduledJob) 36 | class ScheduledJobAdmin(QueueMixin, admin.ModelAdmin): 37 | list_display = ( 38 | 'name', 'job_id', 'is_scheduled', 'scheduled_time', 'enabled') 39 | list_filter = ('enabled', ) 40 | list_editable = ('enabled', ) 41 | 42 | readonly_fields = ('job_id', ) 43 | fieldsets = ( 44 | (None, { 45 | 'fields': ('name', 'callable', 'enabled', ), 46 | }), 47 | (_('RQ Settings'), { 48 | 'fields': ('queue', 'job_id', ), 49 | }), 50 | (_('Scheduling'), { 51 | 'fields': ( 52 | 'scheduled_time', 53 | 'timeout', 54 | 'result_ttl' 55 | ), 56 | }), 57 | ) 58 | 59 | 60 | @admin.register(RepeatableJob) 61 | class RepeatableJobAdmin(QueueMixin, admin.ModelAdmin): 62 | list_display = ( 63 | 'name', 'job_id', 'is_scheduled', 'scheduled_time', 'interval_display', 64 | 'enabled') 65 | list_filter = ('enabled', ) 66 | list_editable = ('enabled', ) 67 | 68 | readonly_fields = ('job_id', ) 69 | fieldsets = ( 70 | (None, { 71 | 'fields': ('name', 'callable', 'enabled', ), 72 | }), 73 | (_('RQ Settings'), { 74 | 'fields': ('queue', 'job_id', ), 75 | }), 76 | (_('Scheduling'), { 77 | 'fields': ( 78 | 'scheduled_time', 79 | ('interval', 'interval_unit', ), 80 | 'repeat', 81 | 'timeout', 82 | 'result_ttl' 83 | ), 84 | }), 85 | ) 86 | 87 | 88 | @admin.register(CronJob) 89 | class CronJobAdmin(QueueMixin, admin.ModelAdmin): 90 | list_display = ( 91 | 'name', 'job_id', 'is_scheduled', 'cron_string', 'enabled') 92 | list_filter = ('enabled', ) 93 | list_editable = ('enabled', ) 94 | 95 | readonly_fields = ('job_id', ) 96 | fieldsets = ( 97 | (None, { 98 | 'fields': ('name', 'callable', 'enabled', ), 99 | }), 100 | (_('RQ Settings'), { 101 | 'fields': ('queue', 'job_id', ), 102 | }), 103 | (_('Scheduling'), { 104 | 'fields': ( 105 | 'cron_string', 106 | 'repeat', 107 | 'timeout', 108 | ), 109 | }), 110 | ) 111 | -------------------------------------------------------------------------------- /scheduler/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | from django.db.models.functions import Now 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | 8 | class SchedulerConfig(AppConfig): 9 | name = 'scheduler' 10 | verbose_name = _('Django RQ Scheduler') 11 | 12 | def ready(self): 13 | try: 14 | self.reschedule_cron_jobs() 15 | self.reschedule_repeatable_jobs() 16 | self.reschedule_scheduled_jobs() 17 | except: 18 | # Django isn't ready yet, example a management command is being 19 | # executed 20 | pass 21 | 22 | def reschedule_cron_jobs(self): 23 | CronJob = self.get_model('CronJob') 24 | jobs = CronJob.objects.filter(enabled=True) 25 | self.reschedule_jobs(jobs) 26 | 27 | def reschedule_repeatable_jobs(self): 28 | RepeatableJob = self.get_model('RepeatableJob') 29 | jobs = RepeatableJob.objects.filter(enabled=True) 30 | self.reschedule_jobs(jobs) 31 | 32 | def reschedule_scheduled_jobs(self): 33 | ScheduledJob = self.get_model('ScheduledJob') 34 | jobs = ScheduledJob.objects.filter( 35 | enabled=True, scheduled_time__lte=Now()) 36 | self.reschedule_jobs(jobs) 37 | 38 | def reschedule_jobs(self, jobs): 39 | for job in jobs: 40 | if job.is_scheduled() is False: 41 | job.save() 42 | -------------------------------------------------------------------------------- /scheduler/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.2 on 2016-03-02 15:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='RepeatableJob', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 23 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 24 | ('name', models.CharField(max_length=128, unique=True, verbose_name='name')), 25 | ('callable', models.CharField(max_length=2048, verbose_name='callable')), 26 | ('enabled', models.BooleanField(default=True, verbose_name='enabled')), 27 | ('queue', models.CharField(choices=[(b'default', b'default'), (b'high', b'high'), (b'low', b'low')], max_length=16, verbose_name='queue')), 28 | ('job_id', models.CharField(blank=True, editable=False, max_length=128, null=True, verbose_name='job id')), 29 | ('scheduled_time', models.DateTimeField(verbose_name='scheduled time')), 30 | ('interval', models.PositiveIntegerField(verbose_name='interval')), 31 | ('interval_unit', models.CharField(choices=[('minutes', 'minutes'), ('hours', 'hours'), ('days', 'days'), ('weeks', 'weeks')], default='hours', max_length=12, verbose_name='interval unit')), 32 | ('repeat', models.PositiveIntegerField(blank=True, null=True, verbose_name='repeat')), 33 | ], 34 | options={ 35 | 'ordering': ('name',), 36 | 'verbose_name': 'Repeatable Job', 37 | 'verbose_name_plural': 'Repeatable Jobs', 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name='ScheduledJob', 42 | fields=[ 43 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 44 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 45 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 46 | ('name', models.CharField(max_length=128, unique=True, verbose_name='name')), 47 | ('callable', models.CharField(max_length=2048, verbose_name='callable')), 48 | ('enabled', models.BooleanField(default=True, verbose_name='enabled')), 49 | ('queue', models.CharField(choices=[(b'default', b'default'), (b'high', b'high'), (b'low', b'low')], max_length=16, verbose_name='queue')), 50 | ('job_id', models.CharField(blank=True, editable=False, max_length=128, null=True, verbose_name='job id')), 51 | ('scheduled_time', models.DateTimeField(verbose_name='scheduled time')), 52 | ], 53 | options={ 54 | 'ordering': ('name',), 55 | 'verbose_name': 'Scheduled Job', 56 | 'verbose_name_plural': 'Scheduled Jobs', 57 | }, 58 | ), 59 | ] 60 | -------------------------------------------------------------------------------- /scheduler/migrations/0002_add_timeout.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-04-30 01:15 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('scheduler', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='repeatablejob', 17 | name='timeout', 18 | field=models.IntegerField(blank=True, help_text="Timeout specifies the maximum runtime, in seconds, for the job before it'll be considered 'lost'. Blank uses the default timeout.", null=True, verbose_name='timeout'), # noqa 19 | ), 20 | migrations.AddField( 21 | model_name='scheduledjob', 22 | name='timeout', 23 | field=models.IntegerField(blank=True, help_text="Timeout specifies the maximum runtime, in seconds, for the job before it'll be considered 'lost'. Blank uses the default timeout.", null=True, verbose_name='timeout'), # noqa 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /scheduler/migrations/0003_remove_queue_choices.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9.5 on 2016-04-30 03:10 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('scheduler', '0002_add_timeout'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='repeatablejob', 17 | name='queue', 18 | field=models.CharField(max_length=16, verbose_name='queue'), 19 | ), 20 | migrations.AlterField( 21 | model_name='scheduledjob', 22 | name='queue', 23 | field=models.CharField(max_length=16, verbose_name='queue'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /scheduler/migrations/0004_add_cron_jobs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-02-14 12:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.utils.timezone 7 | import model_utils.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('scheduler', '0003_remove_queue_choices'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='CronJob', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), 22 | ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), 23 | ('name', models.CharField(max_length=128, unique=True, verbose_name='name')), 24 | ('callable', models.CharField(max_length=2048, verbose_name='callable')), 25 | ('enabled', models.BooleanField(default=True, verbose_name='enabled')), 26 | ('queue', models.CharField(max_length=16, verbose_name='queue')), 27 | ('job_id', models.CharField(blank=True, editable=False, max_length=128, null=True, verbose_name='job id')), 28 | ('timeout', models.IntegerField(blank=True, help_text="Timeout specifies the maximum runtime, in seconds, for the job before it'll be considered 'lost'. Blank uses the default timeout.", null=True, verbose_name='timeout')), 29 | ('repeat', models.PositiveIntegerField(blank=True, null=True, verbose_name='repeat')), 30 | ('cron_string', models.CharField(help_text='Define the schedule in a crontab like syntax.', max_length=64, verbose_name='cron string')), 31 | ], 32 | options={ 33 | 'ordering': ('name',), 34 | 'verbose_name': 'Cron Job', 35 | 'verbose_name_plural': 'Cron Jobs', 36 | }, 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /scheduler/migrations/0005_added_result_ttl.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.6 on 2018-06-29 00:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('scheduler', '0004_add_cron_jobs'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='repeatablejob', 15 | name='result_ttl', 16 | field=models.IntegerField(blank=True, help_text='The TTL value (in seconds) of the job result. -1: Result never expires, you should delete jobs manually. 0: Result gets deleted immediately. >0: Result expires after n seconds.', null=True, verbose_name='result ttl'), 17 | ), 18 | migrations.AddField( 19 | model_name='scheduledjob', 20 | name='result_ttl', 21 | field=models.IntegerField(blank=True, help_text='The TTL value (in seconds) of the job result. -1: Result never expires, you should delete jobs manually. 0: Result gets deleted immediately. >0: Result expires after n seconds.', null=True, verbose_name='result ttl'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /scheduler/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/islco/django-rq-scheduler/901687fe27d241fa21f10838b3f1caffce258054/scheduler/migrations/__init__.py -------------------------------------------------------------------------------- /scheduler/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import importlib 3 | from datetime import timedelta 4 | 5 | import croniter 6 | 7 | from django.conf import settings 8 | from django.core.exceptions import ValidationError 9 | from django.db import models 10 | from django.templatetags.tz import utc 11 | from django.utils.encoding import python_2_unicode_compatible 12 | from django.utils.translation import ugettext_lazy as _ 13 | 14 | import django_rq 15 | from model_utils import Choices 16 | from model_utils.models import TimeStampedModel 17 | 18 | 19 | @python_2_unicode_compatible 20 | class BaseJob(TimeStampedModel): 21 | 22 | name = models.CharField(_('name'), max_length=128, unique=True) 23 | callable = models.CharField(_('callable'), max_length=2048) 24 | enabled = models.BooleanField(_('enabled'), default=True) 25 | queue = models.CharField(_('queue'), max_length=16) 26 | job_id = models.CharField( 27 | _('job id'), max_length=128, editable=False, blank=True, null=True) 28 | timeout = models.IntegerField( 29 | _('timeout'), blank=True, null=True, 30 | help_text=_( 31 | 'Timeout specifies the maximum runtime, in seconds, for the job ' 32 | 'before it\'ll be considered \'lost\'. Blank uses the default ' 33 | 'timeout.' 34 | ) 35 | ) 36 | result_ttl = models.IntegerField( 37 | _('result ttl'), blank=True, null=True, 38 | help_text=_('The TTL value (in seconds) of the job result. -1: ' 39 | 'Result never expires, you should delete jobs manually. ' 40 | '0: Result gets deleted immediately. >0: Result expires ' 41 | 'after n seconds.') 42 | ) 43 | 44 | def __str__(self): 45 | return self.name 46 | 47 | def callable_func(self): 48 | path = self.callable.split('.') 49 | module = importlib.import_module('.'.join(path[:-1])) 50 | func = getattr(module, path[-1]) 51 | if callable(func) is False: 52 | raise TypeError("'{}' is not callable".format(self.callable)) 53 | return func 54 | 55 | def clean(self): 56 | self.clean_callable() 57 | self.clean_queue() 58 | 59 | def clean_callable(self): 60 | try: 61 | self.callable_func() 62 | except: 63 | raise ValidationError({ 64 | 'callable': ValidationError( 65 | _('Invalid callable, must be importable'), code='invalid') 66 | }) 67 | 68 | def clean_queue(self): 69 | queue_keys = settings.RQ_QUEUES.keys() 70 | if self.queue not in queue_keys: 71 | raise ValidationError({ 72 | 'queue': ValidationError( 73 | _('Invalid queue, must be one of: {}'.format( 74 | ', '.join(queue_keys))), code='invalid') 75 | }) 76 | 77 | def is_scheduled(self): 78 | return self.job_id and self.job_id in self.scheduler() 79 | is_scheduled.short_description = _('is scheduled?') 80 | is_scheduled.boolean = True 81 | 82 | def save(self, **kwargs): 83 | self.unschedule() 84 | if self.enabled: 85 | self.schedule() 86 | super(BaseJob, self).save(**kwargs) 87 | 88 | def delete(self, **kwargs): 89 | self.unschedule() 90 | super(BaseJob, self).delete(**kwargs) 91 | 92 | def scheduler(self): 93 | return django_rq.get_scheduler(self.queue) 94 | 95 | def is_schedulable(self): 96 | if self.job_id: 97 | return False 98 | return self.enabled 99 | 100 | def schedule(self): 101 | if self.is_schedulable() is False: 102 | return False 103 | kwargs = {} 104 | if self.timeout: 105 | kwargs['timeout'] = self.timeout 106 | if self.result_ttl is not None: 107 | kwargs['result_ttl'] = self.result_ttl 108 | job = self.scheduler().enqueue_at( 109 | self.schedule_time_utc(), self.callable_func(), 110 | **kwargs 111 | ) 112 | self.job_id = job.id 113 | return True 114 | 115 | def unschedule(self): 116 | if self.is_scheduled(): 117 | self.scheduler().cancel(self.job_id) 118 | self.job_id = None 119 | return True 120 | 121 | def schedule_time_utc(self): 122 | return utc(self.scheduled_time) 123 | 124 | class Meta: 125 | abstract = True 126 | 127 | 128 | class ScheduledTimeMixin(models.Model): 129 | 130 | scheduled_time = models.DateTimeField(_('scheduled time')) 131 | 132 | def schedule_time_utc(self): 133 | return utc(self.scheduled_time) 134 | 135 | class Meta: 136 | abstract = True 137 | 138 | 139 | class ScheduledJob(ScheduledTimeMixin, BaseJob): 140 | 141 | class Meta: 142 | verbose_name = _('Scheduled Job') 143 | verbose_name_plural = _('Scheduled Jobs') 144 | ordering = ('name', ) 145 | 146 | 147 | class RepeatableJob(ScheduledTimeMixin, BaseJob): 148 | 149 | UNITS = Choices( 150 | ('minutes', _('minutes')), 151 | ('hours', _('hours')), 152 | ('days', _('days')), 153 | ('weeks', _('weeks')), 154 | ) 155 | 156 | interval = models.PositiveIntegerField(_('interval')) 157 | interval_unit = models.CharField( 158 | _('interval unit'), max_length=12, choices=UNITS, default=UNITS.hours 159 | ) 160 | repeat = models.PositiveIntegerField(_('repeat'), blank=True, null=True) 161 | 162 | def interval_display(self): 163 | return '{} {}'.format(self.interval, self.get_interval_unit_display()) 164 | 165 | def interval_seconds(self): 166 | kwargs = { 167 | self.interval_unit: self.interval, 168 | } 169 | return timedelta(**kwargs).total_seconds() 170 | 171 | def schedule(self): 172 | if self.is_schedulable() is False: 173 | return False 174 | kwargs = { 175 | 'scheduled_time': self.schedule_time_utc(), 176 | 'func': self.callable_func(), 177 | 'interval': self.interval_seconds(), 178 | 'repeat': self.repeat 179 | } 180 | if self.timeout: 181 | kwargs['timeout'] = self.timeout 182 | if self.result_ttl is not None: 183 | kwargs['result_ttl'] = self.result_ttl 184 | job = self.scheduler().schedule(**kwargs) 185 | self.job_id = job.id 186 | return True 187 | 188 | class Meta: 189 | verbose_name = _('Repeatable Job') 190 | verbose_name_plural = _('Repeatable Jobs') 191 | ordering = ('name', ) 192 | 193 | 194 | class CronJob(BaseJob): 195 | result_ttl = None 196 | 197 | cron_string = models.CharField( 198 | _('cron string'), max_length=64, 199 | help_text=_('Define the schedule in a crontab like syntax.') 200 | ) 201 | repeat = models.PositiveIntegerField(_('repeat'), blank=True, null=True) 202 | 203 | def clean(self): 204 | super(CronJob, self).clean() 205 | self.clean_cron_string() 206 | 207 | def clean_cron_string(self): 208 | try: 209 | croniter.croniter(self.cron_string) 210 | except ValueError as e: 211 | raise ValidationError({ 212 | 'cron_string': ValidationError( 213 | _(str(e)), code='invalid') 214 | }) 215 | 216 | def schedule(self): 217 | if self.is_schedulable() is False: 218 | return False 219 | kwargs = { 220 | 'func': self.callable_func(), 221 | 'cron_string': self.cron_string, 222 | 'repeat': self.repeat 223 | } 224 | if self.timeout: 225 | kwargs['timeout'] = self.timeout 226 | job = self.scheduler().cron(**kwargs) 227 | self.job_id = job.id 228 | return True 229 | 230 | class Meta: 231 | verbose_name = _('Cron Job') 232 | verbose_name_plural = _('Cron Jobs') 233 | ordering = ('name', ) 234 | -------------------------------------------------------------------------------- /scheduler/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from datetime import datetime, timedelta 4 | 5 | from django.conf import settings 6 | from django.core.exceptions import ValidationError 7 | from django.test import TestCase 8 | 9 | import factory 10 | import pytz 11 | from django_rq import job 12 | from scheduler.models import CronJob, RepeatableJob, ScheduledJob 13 | 14 | 15 | class ScheduledJobFactory(factory.Factory): 16 | 17 | name = factory.Sequence(lambda n: 'Addition {}'.format(n)) 18 | job_id = None 19 | queue = 'default' 20 | callable = 'scheduler.tests.test_job' 21 | enabled = True 22 | 23 | @factory.lazy_attribute 24 | def scheduled_time(self): 25 | return datetime.now() + timedelta(days=1) 26 | 27 | class Meta: 28 | model = ScheduledJob 29 | 30 | 31 | class RepeatableJobFactory(factory.Factory): 32 | 33 | name = factory.Sequence(lambda n: 'Addition {}'.format(n)) 34 | job_id = None 35 | queue = 'default' 36 | callable = 'scheduler.tests.test_job' 37 | enabled = True 38 | interval = 1 39 | interval_unit = 'hours' 40 | repeat = None 41 | 42 | @factory.lazy_attribute 43 | def scheduled_time(self): 44 | return datetime.now() + timedelta(minutes=1) 45 | 46 | class Meta: 47 | model = RepeatableJob 48 | 49 | 50 | class CronJobFactory(factory.Factory): 51 | 52 | name = factory.Sequence(lambda n: 'Addition {}'.format(n)) 53 | job_id = None 54 | queue = 'default' 55 | callable = 'scheduler.tests.test_job' 56 | enabled = True 57 | cron_string = "0 0 * * *" 58 | repeat = None 59 | 60 | class Meta: 61 | model = CronJob 62 | 63 | 64 | @job 65 | def test_job(): 66 | return 1 + 1 67 | 68 | 69 | test_non_callable = 'I am a teapot' 70 | 71 | 72 | class TestScheduledJob(TestCase): 73 | 74 | JobClass = ScheduledJob 75 | JobClassFactory = ScheduledJobFactory 76 | 77 | def test_callable_func(self): 78 | job = self.JobClass() 79 | job.callable = 'scheduler.tests.test_job' 80 | func = job.callable_func() 81 | self.assertEqual(test_job, func) 82 | 83 | def test_callable_func_not_callable(self): 84 | job = self.JobClass() 85 | job.callable = 'scheduler.tests.test_non_callable' 86 | with self.assertRaises(TypeError): 87 | job.callable_func() 88 | 89 | def test_clean_callable(self): 90 | job = self.JobClass() 91 | job.callable = 'scheduler.tests.test_job' 92 | assert job.clean_callable() is None 93 | 94 | def test_clean_callable_invalid(self): 95 | job = self.JobClass() 96 | job.callable = 'scheduler.tests.test_non_callable' 97 | with self.assertRaises(ValidationError): 98 | job.clean_callable() 99 | 100 | def test_clean(self): 101 | job = self.JobClass() 102 | job.queue = list(settings.RQ_QUEUES)[0] 103 | job.callable = 'scheduler.tests.test_job' 104 | assert job.clean() is None 105 | 106 | def test_clean_invalid(self): 107 | job = self.JobClass() 108 | job.queue = list(settings.RQ_QUEUES)[0] 109 | job.callable = 'scheduler.tests.test_non_callable' 110 | with self.assertRaises(ValidationError): 111 | job.clean() 112 | 113 | def test_clean_queue_invalid(self): 114 | job = self.JobClass() 115 | job.queue = 'xxxxxx' 116 | job.callable = 'scheduler.tests.test_job' 117 | with self.assertRaises(ValidationError): 118 | job.clean() 119 | 120 | def test_is_schedulable_already_scheduled(self): 121 | job = self.JobClass() 122 | job.job_id = 'something' 123 | self.assertFalse(job.is_schedulable()) 124 | 125 | def test_is_schedulable_disabled(self): 126 | job = self.JobClass() 127 | job.id = 1 128 | job.enabled = False 129 | self.assertFalse(job.is_schedulable()) 130 | 131 | def test_is_schedulable_enabled(self): 132 | job = self.JobClass() 133 | job.id = 1 134 | job.enabled = True 135 | self.assertTrue(job.is_schedulable()) 136 | 137 | def test_schedule(self): 138 | job = self.JobClassFactory() 139 | job.id = 1 140 | successful = job.schedule() 141 | self.assertTrue(successful) 142 | self.assertIsNotNone(job.job_id) 143 | 144 | def test_unschedulable(self): 145 | job = self.JobClassFactory() 146 | job.enabled = False 147 | successful = job.schedule() 148 | self.assertFalse(successful) 149 | self.assertIsNone(job.job_id) 150 | 151 | def test_unschedule(self): 152 | job = self.JobClassFactory() 153 | job.job_id = 'something' 154 | successful = job.unschedule() 155 | self.assertTrue(successful) 156 | self.assertIsNone(job.job_id) 157 | 158 | def test_unschedule_not_scheduled(self): 159 | job = self.JobClassFactory() 160 | successful = job.unschedule() 161 | self.assertTrue(successful) 162 | self.assertIsNone(job.job_id) 163 | 164 | def test_schedule_time_utc(self): 165 | job = self.JobClass() 166 | est = pytz.timezone('US/Eastern') 167 | scheduled_time = datetime(2016, 12, 25, 8, 0, 0, tzinfo=est) 168 | job.scheduled_time = scheduled_time 169 | utc = pytz.timezone('UTC') 170 | expected = scheduled_time.astimezone(utc).isoformat() 171 | self.assertEqual(expected, job.schedule_time_utc().isoformat()) 172 | 173 | def test_save_enabled(self): 174 | job = self.JobClassFactory() 175 | job.save() 176 | self.assertIsNotNone(job.job_id) 177 | 178 | def test_save_disabled(self): 179 | job = self.JobClassFactory() 180 | job.save() 181 | job.enabled = False 182 | job.save() 183 | self.assertIsNone(job.job_id) 184 | 185 | def test_save_and_schedule(self): 186 | job = self.JobClassFactory() 187 | job.id = 1 188 | job.save() 189 | is_scheduled = job.is_scheduled() 190 | self.assertIsNotNone(job.job_id) 191 | self.assertTrue(is_scheduled) 192 | 193 | def test_delete_and_unschedule(self): 194 | job_id = 1 195 | job = self.JobClassFactory() 196 | job.id = job_id 197 | job.save() 198 | is_scheduled = job.is_scheduled() 199 | self.assertIsNotNone(job.job_id) 200 | self.assertTrue(is_scheduled) 201 | scheduler = job.scheduler() 202 | job.delete() 203 | is_scheduled = job_id in scheduler 204 | self.assertFalse(is_scheduled) 205 | 206 | 207 | class TestRepeatableJob(TestScheduledJob): 208 | 209 | JobClass = RepeatableJob 210 | JobClassFactory = RepeatableJobFactory 211 | 212 | def test_interval_seconds_weeks(self): 213 | job = RepeatableJob() 214 | job.interval = 2 215 | job.interval_unit = 'weeks' 216 | self.assertEqual(1209600.0, job.interval_seconds()) 217 | 218 | def test_interval_seconds_days(self): 219 | job = RepeatableJob() 220 | job.interval = 2 221 | job.interval_unit = 'days' 222 | self.assertEqual(172800.0, job.interval_seconds()) 223 | 224 | def test_interval_seconds_hours(self): 225 | job = RepeatableJob() 226 | job.interval = 2 227 | job.interval_unit = 'hours' 228 | self.assertEqual(7200.0, job.interval_seconds()) 229 | 230 | def test_interval_seconds_minutes(self): 231 | job = RepeatableJob() 232 | job.interval = 15 233 | job.interval_unit = 'minutes' 234 | self.assertEqual(900.0, job.interval_seconds()) 235 | 236 | def test_repeatable_schedule(self): 237 | job = self.JobClassFactory() 238 | job.id = 1 239 | successful = job.schedule() 240 | self.assertTrue(successful) 241 | self.assertIsNotNone(job.job_id) 242 | 243 | def test_repeatable_unschedulable(self): 244 | job = self.JobClassFactory() 245 | job.enabled = False 246 | successful = job.schedule() 247 | self.assertFalse(successful) 248 | self.assertIsNone(job.job_id) 249 | 250 | 251 | class TestCronJob(TestScheduledJob): 252 | 253 | JobClass = CronJob 254 | JobClassFactory = CronJobFactory 255 | 256 | def test_clean(self): 257 | job = self.JobClass() 258 | job.cron_string = '* * * * *' 259 | job.queue = list(settings.RQ_QUEUES)[0] 260 | job.callable = 'scheduler.tests.test_job' 261 | assert job.clean() is None 262 | 263 | def test_clean_cron_string_invalid(self): 264 | job = self.JobClass() 265 | job.cron_string = 'not-a-cron-string' 266 | job.queue = list(settings.RQ_QUEUES)[0] 267 | job.callable = 'scheduler.tests.test_job' 268 | with self.assertRaises(ValidationError): 269 | job.clean_cron_string() 270 | 271 | def test_cron_schedule(self): 272 | job = self.JobClassFactory() 273 | job.id = 1 274 | successful = job.schedule() 275 | self.assertTrue(successful) 276 | self.assertIsNotNone(job.job_id) 277 | 278 | def test_cron_unschedulable(self): 279 | job = self.JobClassFactory() 280 | job.enabled = False 281 | successful = job.schedule() 282 | self.assertFalse(successful) 283 | self.assertIsNone(job.job_id) 284 | -------------------------------------------------------------------------------- /scheduler/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from distutils.core import setup 5 | 6 | from setuptools import find_packages 7 | 8 | 9 | def long_desc(root_path): 10 | FILES = ['README.md'] 11 | for filename in FILES: 12 | filepath = os.path.realpath(os.path.join(root_path, filename)) 13 | if os.path.isfile(filepath): 14 | with open(filepath, mode='r') as f: 15 | yield f.read() 16 | 17 | 18 | PATH_OF_RUNNING_SCRIPT = os.path.abspath(os.path.dirname(__file__)) 19 | long_description = "\n\n".join(long_desc(PATH_OF_RUNNING_SCRIPT)) 20 | 21 | 22 | def get_version(root_path): 23 | with open(os.path.join(root_path, 'scheduler', '__init__.py')) as f: 24 | for line in f: 25 | if line.startswith('__version__ ='): 26 | return line.split('=')[1].strip().strip('"\'') 27 | 28 | 29 | tests_require = [ 30 | 'factory_boy>=2.11.1', 31 | ] 32 | 33 | 34 | setup( 35 | name='django-rq-scheduler', 36 | version=get_version(PATH_OF_RUNNING_SCRIPT), 37 | description='A database backed job scheduler for Django RQ', 38 | long_description=long_description, 39 | packages=find_packages(), 40 | include_package_data=True, 41 | author='ISL', 42 | author_email='dev@isl.co', 43 | url='https://github.com/istrategylabs/django-rq-scheduler', 44 | zip_safe=True, 45 | install_requires=[ 46 | 'django>=1.9.0', 47 | 'django-model-utils>=2.4.0', 48 | 'django-rq>=0.9.3', 49 | 'rq-scheduler>=0.6.0', 50 | 'pytz>=2018.5', 51 | 'croniter>=0.3.24', 52 | ], 53 | tests_require=tests_require, 54 | test_suite='scheduler.tests', 55 | extras_require={ 56 | 'test': tests_require, 57 | }, 58 | classifiers=[ 59 | 'Development Status :: 4 - Beta', 60 | 'Environment :: Web Environment', 61 | 'Intended Audience :: Developers', 62 | 'License :: OSI Approved :: MIT License', 63 | 'Operating System :: OS Independent', 64 | 'Programming Language :: Python', 65 | 'Programming Language :: Python :: 2.7', 66 | 'Programming Language :: Python :: 3.6', 67 | 'Framework :: Django', 68 | ], 69 | ) 70 | -------------------------------------------------------------------------------- /testproject19/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject19.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /testproject19/testproject19/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/islco/django-rq-scheduler/901687fe27d241fa21f10838b3f1caffce258054/testproject19/testproject19/__init__.py -------------------------------------------------------------------------------- /testproject19/testproject19/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for testproject19 project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.9.3. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'h0_r$4#4@hgdxy_r0*n8+$(wf0&ie9&4-=(d394n!bo=9rt+85' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'django_rq', 41 | 'scheduler', 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'django.contrib.sessions.middleware.SessionMiddleware', 47 | 'django.middleware.common.CommonMiddleware', 48 | 'django.middleware.csrf.CsrfViewMiddleware', 49 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 50 | 'django.contrib.messages.middleware.MessageMiddleware', 51 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 52 | ] 53 | 54 | ROOT_URLCONF = 'testproject19.urls' 55 | 56 | TEMPLATES = [ 57 | { 58 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 59 | 'DIRS': [], 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ], 68 | }, 69 | }, 70 | ] 71 | 72 | WSGI_APPLICATION = 'testproject19.wsgi.application' 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | 86 | # Password validation 87 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 88 | 89 | AUTH_PASSWORD_VALIDATORS = [ 90 | { 91 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 92 | }, 93 | { 94 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 95 | }, 96 | { 97 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 98 | }, 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 101 | }, 102 | ] 103 | 104 | 105 | # Internationalization 106 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 107 | 108 | LANGUAGE_CODE = 'en-us' 109 | 110 | TIME_ZONE = 'UTC' 111 | 112 | USE_I18N = True 113 | 114 | USE_L10N = True 115 | 116 | USE_TZ = False 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 121 | 122 | STATIC_URL = '/static/' 123 | 124 | # RQ 125 | 126 | RQ_QUEUES = { 127 | 'default': { 128 | 'URL': 'redis://localhost:6379/0', 129 | }, 130 | 'low': { 131 | 'URL': 'redis://localhost:6379/0', 132 | }, 133 | 'high': { 134 | 'URL': 'redis://localhost:6379/0', 135 | }, 136 | } 137 | -------------------------------------------------------------------------------- /testproject19/testproject19/urls.py: -------------------------------------------------------------------------------- 1 | """testproject19 URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.9/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url 17 | from django.contrib import admin 18 | 19 | urlpatterns = [ 20 | url(r'^admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /testproject19/testproject19/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for testproject19 project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject19.settings") 15 | 16 | application = get_wsgi_application() 17 | --------------------------------------------------------------------------------