diff --git a/.gitignore b/.gitignore index b3309b74..a6467409 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ baseframe-packed.css baseframe-packed.js error.log imgee/static/uploads +imgee/static/test_uploads +*.bak +instance/production.env.sh +imgee/static/gen diff --git a/.travis.yml b/.travis.yml index 5d8a38cf..1b11eb41 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ -env: - global: +env: + global: - secure: |- G5Dn+zkbN/BeNoopxtM2idC2Hy1ebJxRxprD7XEAmH6VO26ANgWYLI1dMrdH uQc9F317STawQ/Um6KnqjErOKkC2BUYTOTj8AzoPVFz6NcK/Ca4d9Vfbtf5u @@ -8,17 +8,23 @@ env: SQ9lTuSD5jg3NSM8yMfMU58ppAYBgd+6VQP01wUPFrV/JSsbfgnVjMTm/Nbk EGeHYvQUiyAg7zM4KdNJr6txj+jBE8MAeh7EwYNHoh9B7Vx//GxmXFnWyjXV 9cJkFroDW1Zfs2SZjLtzMQC8YXE30jmMxg+XHCQewKzRa4u1320= + # this is already being set in runtests.sh, + # but need to set it for `./manage.py init` too. + # could set it before running manage.py, but chose to put it here + - FLASK_ENV=TESTING language: python -python: +python: - 2.7 -before_script: - - mkdir imgee/static/test_uploads -script: - - nosetests --with-coverage -install: - - sudo apt-get -qq install zlib1g-dev libfreetype6-dev liblcms1-dev libwebp-dev libjpeg-dev libpng-dev libfreetype6-dev libtiff4-dev librsvg2-dev ghostscript imagemagick pandoc +services: + - redis-server +install: + - sudo apt-get -qq install zlib1g-dev libfreetype6-dev liblcms1-dev libwebp-dev libjpeg-dev libpng-dev libfreetype6-dev libtiff4-dev librsvg2-dev ghostscript imagemagick pandoc inkscape - pip install -r requirements.txt - - pip install nose coverage BeautifulSoup4 Pillow + - pip install -r test_requirements.txt +before_script: + - ./manage.py init # creates the test upload directory +script: + - ./runtests.sh notifications: email: false slack: diff --git a/imgee/__init__.py b/imgee/__init__.py index a6c59506..bde30d7a 100644 --- a/imgee/__init__.py +++ b/imgee/__init__.py @@ -2,8 +2,9 @@ # The imports in this file are order-sensitive -import os -from celery import Celery +import os.path + +from werkzeug.utils import secure_filename from flask import Flask, redirect, url_for from flask_lastuser import Lastuser @@ -16,41 +17,29 @@ version = Version(__version__) app = Flask(__name__, instance_relative_config=True) lastuser = Lastuser() -celery = Celery() assets['imgee.css'][version] = 'css/app.css' from . import models, views from .models import db -from .api import api -from .async import TaskRegistry +from .tasks import TaskRegistry registry = TaskRegistry() -def mkdir_p(dirname): - if not os.path.exists(dirname): - os.makedirs(dirname) - - -# Configure the app - +# Configure the application coaster.app.init_app(app) migrate = Migrate(app, db) baseframe.init_app(app, requires=['baseframe', 'picturefill', 'imgee']) lastuser.init_app(app) lastuser.init_usermanager(UserManager(db, models.User)) +registry.init_app(app) @app.errorhandler(403) def error403(error): return redirect(url_for('login')) -if app.config.get('MEDIA_DOMAIN') and ( - app.config['MEDIA_DOMAIN'].startswith('http:') or - app.config['MEDIA_DOMAIN'].startswith('https:')): +if app.config.get('MEDIA_DOMAIN', '').lower().startswith(('http://', 'https://')): app.config['MEDIA_DOMAIN'] = app.config['MEDIA_DOMAIN'].split(':', 1)[1] -mkdir_p(app.config['UPLOADED_FILES_DEST']) -celery.conf.add_defaults(app.config) -registry.set_connection() -app.register_blueprint(api, url_prefix='/api/1') +app.upload_folder = os.path.join(app.static_folder, secure_filename(app.config.get('UPLOADED_FILES_DIR'))) diff --git a/imgee/api.py b/imgee/api.py deleted file mode 100644 index 29187605..00000000 --- a/imgee/api.py +++ /dev/null @@ -1,60 +0,0 @@ -from flask import jsonify, request, Blueprint, url_for -from coaster.views import load_model, load_models -import os -from urlparse import urljoin - -from imgee import app, lastuser -from imgee.models import db, StoredFile, Profile -import async, utils, storage - -api = Blueprint('api', __name__) - -class Status(object): - ok = 'OK' - in_process = 'PROCESSING' - notfound = 'NOT FOUND' - -@api.errorhandler(404) -def error404(error): - return jsonify({"status": Status.notfound, "status_code": 404}) - - -@api.route('/file/.json') -@load_model(StoredFile, {'name': 'image'}, 'image') -def get_image_json(image): - size = request.args.get('size') - try: - url = utils.get_image_url(image, size) - except async.StillProcessingException as e: - imgname = e.args[0] - url = utils.get_url(imgname) - status = Status.in_process - else: - status = Status.ok - imgee_url = urljoin(request.host_url, url_for('get_image', image=image.name, size=size)) - - d = dict(url=url, status=status, imgee_url=imgee_url, status_code=200) - return jsonify(d) - - -@api.route('//new.json', methods=['POST']) -@load_model(Profile, {'name': 'profile'}, 'profile') -@lastuser.resource_handler('imgee/new') -def upload_file_json(callerinfo, profile): - file_ = request.files['file'] - title = request.form.get('title') - title, job = storage.save(file_, profile=profile, title=title) - try: - imgname = async.get_async_result(job) - except async.StillProcessingException as e: - imgname = e.args[0] - status = Status.in_process - else: - status = Status.ok - - url = utils.get_url(imgname) - imgname = os.path.splitext(imgname)[0] - imgee_url = urljoin(request.host_url, url_for('get_image', image=imgname)) - d = dict(url=url, status=status, imgee_url=imgee_url, status_code=200) - return jsonify(d) - diff --git a/imgee/async.py b/imgee/async.py deleted file mode 100644 index 0684b97c..00000000 --- a/imgee/async.py +++ /dev/null @@ -1,107 +0,0 @@ -import redis -from celery import Task -import celery.states -from celery.result import AsyncResult, EagerResult -from flask import url_for, redirect, current_app, make_response -import time - -import imgee -from imgee import app -from imgee.models import db -import storage, utils - -def now_in_secs(): - return int(time.time()) - -def get_taskid(funcname, imgname): - return "{f}:{n}".format(f=funcname, n=imgname) - - -class BaseTask(celery.Task): - abstract = True - def after_return(self, status, retval, task_id, args, kwargs, einfo): - # even if the task fails remove task_id so that on next request the task is executed. - imgee.registry.remove(task_id) - - def on_failure(self, exc, task_id, args, kwargs, einfo): - super(BaseTask, self).on_failure(exc, task_id, args, kwargs, einfo) - db.session.rollback() - - -class TaskRegistry(object): - def __init__(self, name='default', connection=None): - self.connection = redis.from_url(connection) if connection else None - self.name = name - self.key = 'imgee:registry:%s' % name - - def set_connection(self, connection=None): - connection = connection or app.config.get('REDIS_URL') - self.connection = redis.from_url(connection) - - def add(self, taskid): - self.connection.sadd(self.key, taskid) - - def remove(self, taskid): - self.connection.srem(self.key, taskid) - - def __contains__(self, taskid): - return self.connection.sismember(self.key, taskid) - - def keys_starting_with(self, exp): - return [k for k in self.connection.smembers(self.key) if k.startswith(exp)] - - def is_queued_for_deletion(self, imgname): - taskid = get_taskid('delete', imgname) - return taskid in self - - -def queueit(funcname, *args, **kwargs): - """ - Execute `funcname` function with `args` and `kwargs` if CELERY_ALWAYS_EAGER is True. - Otherwise, check if it's queued already in `TaskRegistry`. If not, add it to `TaskRegistry` and queue it. - """ - - func = getattr(storage, funcname) - taskid = get_taskid(funcname, kwargs.pop('taskid')) - if app.config.get('CELERY_ALWAYS_EAGER'): - return func(*args, **kwargs) - else: - # check it in the registry. - if taskid in imgee.registry: - job = AsyncResult(taskid, app=imgee.celery) - if job.status == celery.states.SUCCESS: - return job.result - else: - # add in the registry and enqueue the job - imgee.registry.add(taskid) - job = func.apply_async(args=args, kwargs=kwargs, task_id=taskid) - return job - - -def loading(): - """ - Returns the `LOADING_IMG` as the content of the response. - """ - with open(app.config.get('LOADING_IMG')) as loading_img: - response = make_response(loading_img.read()) - response.headers['Content-Type'] = utils.get_file_type(loading_img) - return response - - -class StillProcessingException(Exception): - pass - - -def get_async_result(job): - """ - If the result of the `job` is not yet ready, return that else raise StillProcessingException. - If the input is `str` instead, return that. - """ - if isinstance(job, AsyncResult): - if job.status == celery.states.SUCCESS: - return job.result - else: - img_name = job.task_id.split(':')[1] - raise StillProcessingException(img_name) - elif isinstance(job, (str, unicode)): - return job diff --git a/imgee/forms.py b/imgee/forms.py index 65601499..5ba69c03 100644 --- a/imgee/forms.py +++ b/imgee/forms.py @@ -1,19 +1,17 @@ # -*- coding: utf-8 -*- -import os.path from coaster.utils import make_name -from flask_wtf import Form +from baseframe.forms import Form from wtforms.validators import Required, ValidationError, Length -from wtforms import (FileField, TextField, HiddenField, - SelectMultipleField, SelectField) +from wtforms import (FileField, TextField, HiddenField, SelectField) from imgee import app -from imgee.models import Label -from imgee.utils import get_file_type, is_file_allowed +from .models import Label +from .utils import is_file_allowed def valid_file(form, field): - if not is_file_allowed(field.data.stream): + if not is_file_allowed(field.data.stream, field.data.mimetype, field.data.filename): raise ValidationError("Sorry, unknown image format. Please try uploading another file.") @@ -28,8 +26,9 @@ class DeleteImageForm(Form): class PurgeCacheForm(Form): pass + def reserved_words(): - """get all words which can't be used as labels""" + """Get all words which can't be used as labels""" words = [] for rule in app.url_map.iter_rules(): r = rule.rule @@ -67,6 +66,7 @@ class EditTitleForm(Form): file_name = HiddenField('file_name') file_title = TextField('title', validators=[Required(), Length(max=250)]) + class UpdateTitle(Form): title = TextField('Title', validators=[Required(), Length(max=250)]) diff --git a/imgee/models/profile.py b/imgee/models/profile.py index 689cf288..ccafeaf6 100644 --- a/imgee/models/profile.py +++ b/imgee/models/profile.py @@ -1,6 +1,6 @@ from coaster.sqlalchemy import BaseNameMixin from flask_lastuser.sqlalchemy import ProfileMixin -from imgee.models import db +from . import db class Profile(ProfileMixin, BaseNameMixin, db.Model): diff --git a/imgee/models/stored_file.py b/imgee/models/stored_file.py index f39d5936..596121b6 100644 --- a/imgee/models/stored_file.py +++ b/imgee/models/stored_file.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- +from flask import url_for from coaster.sqlalchemy import BaseNameMixin, BaseScopedNameMixin -import imgee -from imgee.models import db -from imgee.utils import newid, guess_extension +from .. import app +from . import db +from ..utils import newid, guess_extension image_labels = db.Table('image_labels', @@ -58,7 +59,34 @@ def __repr__(self): def extn(self): return guess_extension(self.mimetype, self.orig_extn) or '' - def is_queued_for_deletion(self): - if imgee.app.config.get('CELERY_ALWAYS_EAGER'): - return False - return imgee.registry.is_queued_for_deletion(self.name+self.extn) + @property + def filename(self): + return self.name + self.extn + + def dict_data(self): + return dict( + title=self.title, + uploaded=self.created_at.isoformat() + 'Z', + filesize=app.jinja_env.filters['filesizeformat'](self.size), + imgsize=u'%s×%s px' % (self.width, self.height), + url=url_for('view_image', profile=self.profile.name, image=self.name), + thumb_url=url_for('get_image', image=self.name, size=app.config.get('THUMBNAIL_SIZE')) + ) + + def add_labels(self, labels): + status = { + '+': [], + '-': [], + '': [] + } + + new_labels = set(labels) + old_labels = set(self.labels) + if new_labels != old_labels: + self.labels = labels + + status['+'] = new_labels - old_labels # added labels + status['-'] = old_labels - new_labels # removed labels + status[''] = old_labels.intersection(new_labels) # unchanged labels + + return status diff --git a/imgee/models/thumbnail.py b/imgee/models/thumbnail.py index 06010018..d90a4944 100644 --- a/imgee/models/thumbnail.py +++ b/imgee/models/thumbnail.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- from coaster.sqlalchemy import BaseMixin -from imgee.models import db -from imgee.utils import newid +from . import db +from ..utils import newid class Thumbnail(BaseMixin, db.Model): diff --git a/imgee/models/user.py b/imgee/models/user.py index 6a916de7..24d5c96c 100644 --- a/imgee/models/user.py +++ b/imgee/models/user.py @@ -3,8 +3,8 @@ from flask import url_for from flask_lastuser.sqlalchemy import UserBase from werkzeug.utils import cached_property -from imgee.models import db -from imgee.models.profile import Profile +from . import db +from .profile import Profile class User(UserBase, db.Model): diff --git a/imgee/storage.py b/imgee/storage.py index e12d1db9..4055aa44 100644 --- a/imgee/storage.py +++ b/imgee/storage.py @@ -1,51 +1,77 @@ # -*- coding: utf-8 -*- -import time -import os.path -from subprocess import check_call, CalledProcessError +from datetime import datetime, timedelta from glob import glob +import os.path +import os import re -from celery.result import AsyncResult +from subprocess import check_call, CalledProcessError +import time + from sqlalchemy import or_ from werkzeug import secure_filename import imgee -from imgee import app, celery -from imgee.models import db, Thumbnail, StoredFile -from imgee.async import queueit, get_taskid, BaseTask -from imgee.utils import (newid, guess_extension, get_file_type, - path_for, get_s3_folder, get_s3_bucket, - download_frm_s3, get_width_height, ALLOWED_MIMETYPES) +from . import app +from .models import db, Thumbnail, StoredFile +from .utils import ( + newid, guess_extension, get_file_type, is_animated_gif, + path_for, get_s3_folder, get_s3_bucket, + download_from_s3, get_width_height, ALLOWED_MIMETYPES, + exists_in_s3, THUMBNAIL_COMMANDS +) # -- functions used in views -- def get_resized_image(img, size, is_thumbnail=False): """ - Check if `img` is available with `size` if not make a one. Return the name of it. + Check if `img` is available with `size` if not make one. Return the name of it. """ + registry = imgee.registry img_name = img.name + + if img.mimetype == 'image/gif': + # if the gif file is animated, not resizing it for now + # but we will need to resize the gif, keeping animation intact + # https://github.com/hasgeek/imgee/issues/55 + src_path = download_from_s3(img.filename) + if is_animated_gif(src_path): + return img.name + size_t = parse_size(size) if (size_t and size_t[0] != img.width and size_t[1] != img.height) or ('thumb_extn' in ALLOWED_MIMETYPES[img.mimetype] and ALLOWED_MIMETYPES[img.mimetype]['thumb_extn'] != img.extn): w_or_h = or_(Thumbnail.width == size_t[0], Thumbnail.height == size_t[1]) scaled = Thumbnail.query.filter(w_or_h, Thumbnail.stored_file == img).first() - if scaled: + if scaled and exists_in_s3(scaled): img_name = scaled.name else: - size = get_fitting_size((img.width, img.height), size_t) + original_size = (img.width, img.height) + size = get_fitting_size(original_size, size_t) resized_filename = get_resized_filename(img, size) - job = queueit('resize_and_save', img, size, is_thumbnail=is_thumbnail, taskid=resized_filename) - return job + + if resized_filename in registry: + # file is still being processed + # returning None will show "no preview available" thumbnail + return None + + try: + registry.add(resized_filename) + img_name = resize_and_save(img, size, is_thumbnail=is_thumbnail) + finally: + # file has been processed, remove from registry + if resized_filename in registry: + registry.remove(resized_filename) return img_name -def save(fp, profile, title=None): +def save_file(fp, profile, title=None): """ Attaches the image to the profile and uploads it to S3. """ id_ = newid() title = title or secure_filename(fp.filename) - content_type = get_file_type(fp) + content_type = get_file_type(fp, fp.filename) name, extn = os.path.splitext(fp.filename) extn = guess_extension(content_type, extn) img_name = "%s%s" % (id_, extn) @@ -56,8 +82,8 @@ def save(fp, profile, title=None): stored_file = save_img_in_db(name=id_, title=title, local_path=local_path, profile=profile, mimetype=content_type, orig_extn=extn) - job = queueit('save_on_s3', img_name, content_type=content_type, taskid=img_name) - return title, job, stored_file + save_on_s3(img_name, content_type=content_type) + return title, stored_file # -- actual saving of image/thumbnail and data in the db and on S3. @@ -77,18 +103,25 @@ def save_img_in_db(name, title, local_path, profile, mimetype, orig_extn): return stored_file -def save_tn_in_db(img, tn_name, (tn_w, tn_h)): +def save_tn_in_db(img, tn_name, size): """ Save thumbnail info in db. + + tn_name: Name of the thumbnail file. + e.g. eecffa912ef111e787d65f851b2b7883_w75_h45 + + size: A tuple in the format (width, height) + e.g. (480, 360) """ + tn_w, tn_h = size name, extn = os.path.splitext(tn_name) - tn = Thumbnail(name=name, width=tn_w, height=tn_h, stored_file=img) - db.session.add(tn) - db.session.commit() + if Thumbnail.query.filter(Thumbnail.name == name).isempty(): + tn = Thumbnail(name=name, width=tn_w, height=tn_h, stored_file=img) + db.session.add(tn) + db.session.commit() return name -@celery.task(name='imgee.storage.s3-upload', base=BaseTask) def save_on_s3(filename, remotename='', content_type='', bucket='', folder=''): """ Save contents from file named `filename` to `remotename` on S3. @@ -101,13 +134,14 @@ def save_on_s3(filename, remotename='', content_type='', bucket='', folder=''): k = b.new_key(folder+filename) headers = { 'Cache-Control': 'max-age=31536000', # 60*60*24*365 - 'Content-Type': get_file_type(fp), + 'Content-Type': get_file_type(fp, filename), + # once cached, it is set to expire after a year + 'Expires': datetime.utcnow() + timedelta(days=365) } k.set_contents_from_file(fp, policy='public-read', headers=headers) return filename - # -- size calculations -- def parse_size(size): @@ -127,12 +161,14 @@ def parse_size(size): return tuple(map(int, size)) -def get_fitting_size((orig_w, orig_h), size): +def get_fitting_size(original_size, size): """ Return the size to fit the image to the box along the smaller side and preserve aspect ratio. w or h being 0 means preserve aspect ratio with that height or width + >>> get_fitting_size((0, 0), (200, 500)) + [200, 500] >>> get_fitting_size((200, 500), (0, 0)) [200, 500] >>> get_fitting_size((200, 500), (400, 0)) @@ -150,7 +186,13 @@ def get_fitting_size((orig_w, orig_h), size): >>> get_fitting_size((200, 500), (400, 600)) [240, 600] """ - if size[0] == 0 and size[1] == 0: + orig_w, orig_h = original_size + + if orig_w == 0 or orig_h == 0: + # this is either a cdr file or a zero width file + # just go with target size + w, h = size + elif size[0] == 0 and size[1] == 0 and orig_w > 0 and orig_h > 0: w, h = orig_w, orig_h elif size[0] == 0: w, h = orig_w*size[1]/float(orig_h), size[1] @@ -186,13 +228,12 @@ def get_resized_filename(img, size): return name -@celery.task(name='imgee.storage.resize-and-s3-upload', base=BaseTask) def resize_and_save(img, size, is_thumbnail=False): """ Get the original image from local disk cache, download it from S3 if it misses. Resize the image and save resized image on S3 and size details in db. """ - src_path = download_frm_s3(img.name + img.extn) + src_path = download_from_s3(img.filename) if 'thumb_extn' in ALLOWED_MIMETYPES[img.mimetype]: format = ALLOWED_MIMETYPES[img.mimetype]['thumb_extn'] @@ -221,38 +262,23 @@ def resize_img(src, dest, size, mimetype, format, is_thumbnail): if not size: return src - processed = False - - if 'processor' in ALLOWED_MIMETYPES[mimetype]: - if ALLOWED_MIMETYPES[mimetype]['processor'] == 'rsvg-convert': - try: - check_call('rsvg-convert --width=%s --height=%s --keep-aspect-ratio=TRUE --format=%s %s > %s' - % (size[0], size[1], format, src, dest), shell=True) - except CalledProcessError as e: - return False - processed = True - if not processed: - try: - check_call('convert -quiet -thumbnail %sx%s %s -colorspace sRGB %s' % (size[0], size[1], src, dest), shell=True) - except CalledProcessError: - return False - - # if is_thumbnail: - # # and crop the rest, keeping the center. - # w, h = resized.size - # tw, th = map(int, app.config.get('THUMBNAIL_SIZE').split('x')) - # left, top = int((w-tw)/2), int((h-th)/2) - # resized = resized.crop((left, top, left+tw, top+th)) - - return True + # get processor value, if none specified, use convert + processor = ALLOWED_MIMETYPES[mimetype].get('processor', 'convert') + command = THUMBNAIL_COMMANDS.get(processor) + prepared_command = command.format(width=size[0], height=size[1], format=format, src=src, dest=dest) + try: + check_call(prepared_command, shell=True) + return True + except CalledProcessError as e: + raise e def clean_local_cache(expiry=24): """ - Remove files from local cache which are NOT accessed in the last `expiry` hours. + Remove files from local cache + which are NOT accessed in the last `expiry` hours. """ - cache_path = app.config.get('UPLOADED_FILES_DEST') - cache_path = os.path.join(cache_path, '*') + cache_path = os.path.join(app.upload_folder, '*') min_atime = time.time() - expiry*60*60 n = 0 @@ -263,35 +289,19 @@ def clean_local_cache(expiry=24): return n -def wait_for_asynctasks(stored_file): - registry = imgee.registry - - if not registry.connection: - return - - # wait for upload to be complete, if any. - taskid = get_taskid('save_on_s3', stored_file.name+stored_file.extn) - if taskid in registry: - AsyncResult(taskid).get() - - # wait for all resizes to be complete, if any. - s = get_taskid('resize_and_save', stored_file.name) - for taskid in registry.keys_starting_with(s): - AsyncResult(taskid).get() - - -@celery.task(name='imgee.storage.delete', base=BaseTask) -def delete(stored_file): +def delete(stored_file, commit=True): """ Delete all the thumbnails and images associated with a file, from local cache and S3. - Wait for the upload/resize to complete if queued for the same image. """ - if not app.config.get('CELERY_ALWAYS_EAGER'): - wait_for_asynctasks(stored_file) + registry = imgee.registry + # remove all the keys related to the given file name + # this is delete all keys matching `imgee:registry:*` + registry.remove_keys_starting_with(stored_file.name) # remove locally - cache_path = app.config.get('UPLOADED_FILES_DEST') - cached_img_path = os.path.join(cache_path, '%s*' % stored_file.name) + cache_path = app.upload_folder + os.remove(os.path.join(cache_path, stored_file.filename)) + cached_img_path = os.path.join(cache_path, stored_file.name + '_*') for f in glob(cached_img_path): os.remove(f) @@ -305,12 +315,13 @@ def delete(stored_file): bucket.delete_keys(keys) # remove from the db - # remove thumbnails explicitly. # cascade rules don't work as lazy loads don't work in async mode Thumbnail.query.filter_by(stored_file=stored_file).delete() db.session.delete(stored_file) - db.session.commit() + + if commit: + db.session.commit() if __name__ == '__main__': diff --git a/imgee/tasks.py b/imgee/tasks.py new file mode 100644 index 00000000..f5279c7c --- /dev/null +++ b/imgee/tasks.py @@ -0,0 +1,85 @@ +import re +import redis + + +class InvalidRedisQueryException(Exception): + pass + + +class TaskRegistry(object): + def __init__(self, app=None, name='default', connection=None): + self.name = name + self.connection = connection + self.key_prefix = 'imgee:registry:%s' % name + self.filename_pattern = re.compile(r'^[a-z0-9_\.]+$') + + if app: + self.init_app(app) + else: + self.app = None + + def init_app(self, app): + self.app = app + + if not self.connection: + url = self.app.config.get('REDIS_URL') + self.connection = redis.from_url(url) + self.pipe = self.connection.pipeline() + + def is_valid_query(self, query): + return bool(self.filename_pattern.match(query)) + + def key_for(self, taskid): + return u'{key_prefix}:{taskid}'.format(key_prefix=self.key_prefix, taskid=taskid) + + def __contains__(self, taskid): + return len(self.connection.keys(self.key_for(taskid))) > 0 + + def add(self, taskid, expire=60): + self.connection.set(self.key_for(taskid), taskid) + # setting TTL of 60 seconds for the key + # if the file doesn't get processed within 60 seconds, + # it'll be removed from the registry + self.connection.expire(self.key_for(taskid), expire) + + def search(self, query): + # >> KEYS imgee:registry:default:*query* + if not self.is_valid_query(query): + raise InvalidRedisQueryException(u'Invalid query for searching redis keys: {}'.format(query)) + return self.connection.keys(self.key_for('*{}*'.format(query))) + + def get_all_keys(self): + # >> KEYS imgee:registry:default:* + return self.connection.keys(self.key_for('*')) + + def keys_starting_with(self, query): + # >> KEYS imgee:registry:default:query_* + # chances are that we'll use this function to find the + # thumbnail keys, which look like "name_wNN_hNN", hence the _ + if not self.is_valid_query(query): + raise InvalidRedisQueryException(u'Invalid query for searching redis keys, starting with: {}'.format(query)) + return self.connection.keys(self.key_for('{}_*'.format(query))) + + def remove(self, taskid): + # remove a key with the taskid + self.remove_by_key(self.key_for(taskid)) + + def remove_by_key(self, key): + # remove a redis key directly + self.connection.delete(key) + + def remove_keys(self, keys): + for k in keys: + self.pipe.delete(k) + self.pipe.execute() + + def remove_keys_matching(self, query): + self.remove_keys(self.search(query)) + + def remove_keys_starting_with(self, query): + # query will most likely be stored_file.name, So + # Delete keys for only `` and `_*` + self.remove_keys([self.key_for(query)] + self.keys_starting_with(query)) + + def remove_all(self): + self.remove_keys(self.get_all_keys()) diff --git a/imgee/templates/profile.html b/imgee/templates/profile.html index ae98a1a4..bd250a77 100644 --- a/imgee/templates/profile.html +++ b/imgee/templates/profile.html @@ -7,11 +7,12 @@ {{ title_form.hidden_tag() }}
+ {% endif %}
-{% endblock %} +{% endblock %} {% block maincontent %}
@@ -44,7 +46,7 @@

@@ -95,14 +97,14 @@

-
+
- +
- -
+

- {% endfor %} + {% endfor %}
- + @@ -167,7 +169,7 @@

$(function(){ $('.dropzone').css('min-height', '30px'); - }); + });