diff --git a/maintenancemode/conf.py b/maintenancemode/conf.py index c59b0f7..679208d 100644 --- a/maintenancemode/conf.py +++ b/maintenancemode/conf.py @@ -11,6 +11,11 @@ class MaintenanceSettings(AppConf): LOCKFILE_PATH = os.path.join( os.path.abspath(os.path.dirname(__file__)), 'maintenance.lock') MODE = False + LOCKING_METHOD = 'file' + CACHE_KEY = "DJANGO_MAINTENANCE_MODE_ON" + CACHE_TTL = 60 * 60 * 24 + CACHE_BACKEND = "default" + MAX_WAIT_FOR_END = 0 class Meta: prefix = 'maintenance' diff --git a/maintenancemode/management/commands/maintenance.py b/maintenancemode/management/commands/maintenance.py index 67117c1..1adaaf3 100644 --- a/maintenancemode/management/commands/maintenance.py +++ b/maintenancemode/management/commands/maintenance.py @@ -16,17 +16,33 @@ def handle(self, *args, **options): verbosity = int(options.get('verbosity')) if command is not None: - if command.lower() in ('on', 'activate'): + try: + maintenance_duration = int(command) + except ValueError: + maintenance_duration = None + if maintenance_duration: + maintenance.activate(maintenance_duration) + if verbosity > 0: + self.stdout.write( + "Maintenance mode was activated " + "succesfully for %s seconds" % maintenance_duration + ) + return + elif command.lower() in ('on', 'activate'): maintenance.activate() if verbosity > 0: self.stdout.write( "Maintenance mode was activated succesfully") + return elif command.lower() in ('off', 'deactivate'): maintenance.deactivate() if verbosity > 0: self.stdout.write( "Maintenance mode was deactivated succesfully") + return if command not in self.opts: raise CommandError( - "Allowed commands are: %s" % '|'.join(self.opts)) + "Allowed commands are: %s or maintenance duration in seconds" + % '|'.join(self.opts) + ) diff --git a/maintenancemode/middleware.py b/maintenancemode/middleware.py index dd5234a..9f07afd 100644 --- a/maintenancemode/middleware.py +++ b/maintenancemode/middleware.py @@ -1,25 +1,69 @@ # -*- coding: utf-8 -*- import re -import django +from datetime import datetime +from time import mktime +from wsgiref.handlers import format_date_time +from time import sleep +import logging +import django from django.conf import urls from django.core import urlresolvers -from .conf import settings from . import utils as maintenance +from .conf import settings urls.handler503 = 'maintenancemode.views.temporary_unavailable' urls.__all__.append('handler503') -IGNORE_URLS = tuple([re.compile(u) for u in settings.MAINTENANCE_IGNORE_URLS]) +IGNORE_URLS = tuple([re.compile(u) for u in getattr(settings, 'MAINTENANCE_IGNORE_URLS', [])]) +MAX_WAIT_FOR_END = getattr(settings, 'MAINTENANCE_MAX_WAIT_FOR_END', 60) + +logger = logging.getLogger(__name__) class MaintenanceModeMiddleware(object): + def cond_wait_for_end_of_maintenance(self, request, retry_after): + """ + Wait for remaining maintenance time if waiting time is + less than MAX_WAIT_FOR_END + """ + ends_in = (retry_after - datetime.now()).total_seconds() + max_wait = MAX_WAIT_FOR_END + if ends_in > 0 and ends_in < max_wait: + logger.info( + u"[%s] waiting for %ss" % ( + request.path, ends_in + ) + ) + sleep(ends_in) + return + def process_request(self, request): - # Allow access if middleware is not activated - if not (settings.MAINTENANCE_MODE or maintenance.status()): + if not hasattr(settings, 'MAINTENANCE_MODE'): + # package not setup + return None + + value = getattr(settings, 'MAINTENANCE_MODE', False) or maintenance.status() + if not value: + # maintenance not active + return None + + if isinstance(value, datetime): + retry_after = value + else: + retry_after = None + + # used by template + request.retry_after = retry_after + + if retry_after: + self.cond_wait_for_end_of_maintenance(request, retry_after) + + if retry_after and datetime.now() > retry_after: + # maintenance ended return None INTERNAL_IPS = maintenance.IPList(settings.INTERNAL_IPS) @@ -52,4 +96,11 @@ def process_request(self, request): else: callback, param_dict = resolver.resolve_error_handler('503') - return callback(request, **param_dict) + response = callback(request, **param_dict) + + if retry_after: + response["Retry-After"] = format_date_time( + mktime(retry_after.timetuple()) + ) + + return response diff --git a/maintenancemode/utils.py b/maintenancemode/utils.py index d4b7253..fc9286e 100644 --- a/maintenancemode/utils.py +++ b/maintenancemode/utils.py @@ -2,8 +2,19 @@ import os +from dateutil.relativedelta import relativedelta +from datetime import datetime +from django.core.cache import get_cache +from django.core.exceptions import ImproperlyConfigured + from .conf import settings +LOCKING_METHOD = settings.MAINTENANCE_LOCKING_METHOD + +CACHE_BACKEND = settings.MAINTENANCE_CACHE_BACKEND + +cache = get_cache(CACHE_BACKEND) + class IPList(list): """Stolen from https://djangosnippets.org/snippets/1362/""" @@ -26,18 +37,50 @@ def __contains__(self, ip): return False -def activate(): - try: - open(settings.MAINTENANCE_LOCKFILE_PATH, 'ab', 0).close() - except OSError: - pass # shit happens +def activate(maintenance_duration=None): + if maintenance_duration: + cache_value = ( + datetime.now() + relativedelta(seconds=maintenance_duration) + ) + else: + cache_value = True + if LOCKING_METHOD == "file": + try: + open(settings.MAINTENANCE_LOCKFILE_PATH, 'ab', 0).close() + except OSError: + pass # shit happens + elif LOCKING_METHOD == "cache": + cache.set( + settings.MAINTENANCE_CACHE_KEY, + cache_value, + settings.MAINTENANCE_CACHE_TTL + ) + else: + raise ImproperlyConfigured( + "Unknown locking method %s" % LOCKING_METHOD) def deactivate(): - if os.path.isfile(settings.MAINTENANCE_LOCKFILE_PATH): - os.remove(settings.MAINTENANCE_LOCKFILE_PATH) + LOCKING_METHOD = settings.MAINTENANCE_LOCKING_METHOD + if LOCKING_METHOD == "file": + if os.path.isfile(settings.MAINTENANCE_LOCKFILE_PATH): + os.remove(settings.MAINTENANCE_LOCKFILE_PATH) + elif LOCKING_METHOD == "cache": + cache.delete( + settings.MAINTENANCE_CACHE_KEY + ) + else: + raise ImproperlyConfigured( + "Unknown locking method %s" % LOCKING_METHOD) def status(): - return settings.MAINTENANCE_MODE or os.path.isfile( - settings.MAINTENANCE_LOCKFILE_PATH) + LOCKING_METHOD = settings.MAINTENANCE_LOCKING_METHOD + if LOCKING_METHOD == "file": + return settings.MAINTENANCE_MODE or os.path.isfile( + settings.MAINTENANCE_LOCKFILE_PATH) + elif LOCKING_METHOD == "cache": + return cache.get(settings.MAINTENANCE_CACHE_KEY) + else: + raise ImproperlyConfigured( + "Unknown locking method %s" % LOCKING_METHOD) diff --git a/maintenancemode/views.py b/maintenancemode/views.py index a45e873..ef4baa0 100644 --- a/maintenancemode/views.py +++ b/maintenancemode/views.py @@ -30,6 +30,7 @@ def temporary_unavailable(request, template_name='503.html'): """ context = { 'request_path': request.path, + 'request': request } return http.HttpResponseTemporaryUnavailable( render_to_string(template_name, context)) diff --git a/setup.py b/setup.py index fc5c2a0..ed77907 100644 --- a/setup.py +++ b/setup.py @@ -36,12 +36,10 @@ def find_version(*parts): license='BSD License', install_requires=[ - 'django>=1.4.2', 'django-appconf', 'ipy', ], requires=[ - 'Django (>=1.4.2)', ], description="django-maintenancemode allows you to temporary shutdown your site for maintenance work",