Add Redis storage POCpull/170/head
| @ -0,0 +1,103 @@ | |||
| """ | |||
| Redis storages backend to help store binary data. | |||
| """ | |||
| import base64 | |||
| import io | |||
| import redis | |||
| import uuid | |||
| import re | |||
| from shutil import copyfileobj | |||
| from tempfile import SpooledTemporaryFile | |||
| from django.conf import settings | |||
| from django.core.files.base import File | |||
| from django.core.files.storage import Storage | |||
| from django.utils.deconstruct import deconstructible | |||
| EX_EXPIRY = 60*24*7 # 1 week expiry | |||
| def generate_unique_filename(instance, filename): | |||
| return '{}_{}'.format(uuid.uuid4(), re.sub('[^0-9a-zA-Z]+', '_', filename)) | |||
| class RedisFile(File): | |||
| def __init__(self, name, storage): | |||
| self.name = name | |||
| self._storage = storage | |||
| self._file = None | |||
| def _get_file(self): | |||
| if self._file is None: | |||
| self._file = SpooledTemporaryFile() | |||
| # get from redis | |||
| content = self._storage.client.get(self.name) | |||
| # stored as base64 .. decode | |||
| content = base64.b64decode(content) | |||
| with io.BytesIO(content) as file_content: | |||
| copyfileobj(file_content, self._file) | |||
| self._file.seek(0) | |||
| return self._file | |||
| def _set_file(self, value): | |||
| self._file = value | |||
| file = property(_get_file, _set_file) | |||
| @deconstructible | |||
| class RedisStorage(Storage): | |||
| def __init__(self): | |||
| self.client = redis.Redis( | |||
| host=settings.REDIS_HOST, | |||
| port=settings.REDIS_PORT, | |||
| db=settings.REDIS_DB, | |||
| password=settings.REDIS_PASSWORD | |||
| ) | |||
| def _full_key_name(self, name): | |||
| return name | |||
| def delete(self, name): | |||
| self.client.delete(self._full_key_name(name)) | |||
| def exists(self, name): | |||
| return self.client.exists(self._full_key_name(name)) | |||
| def listdir(self, path): | |||
| return '', '' | |||
| def size(self, name): | |||
| return '' | |||
| def url(self, name): | |||
| return '' | |||
| def _open(self, name, mode='rb'): | |||
| remote_file = RedisFile(self._full_key_name(name), self) | |||
| return remote_file | |||
| def _save(self, name, content): | |||
| content.open() | |||
| data = base64.b64encode(content.read()) | |||
| self.client.set(self._full_key_name(name), data, ex=EX_EXPIRY) | |||
| content.close() | |||
| return name | |||
| def get_available_name(self, name, max_length=None): | |||
| """ | |||
| Allow storage backend to generate a new name if there is already an existing file. Not used for this Redis | |||
| implementation. | |||
| """ | |||
| name = self._full_key_name(name) | |||
| return super().get_available_name(name, max_length) | |||
| @ -0,0 +1,94 @@ | |||
| from unittest import mock | |||
| from django.core.files.uploadedfile import SimpleUploadedFile | |||
| from django.test import TransactionTestCase | |||
| from redis.exceptions import ConnectionError | |||
| from edivorce.apps.core.redis import generate_unique_filename | |||
| from edivorce.apps.poc.models import Document | |||
| class UploadStorageTests(TransactionTestCase): | |||
| @mock.patch('redis.connection.ConnectionPool.get_connection') | |||
| def test_storage_connection_error(self, mock_redis): | |||
| mock_redis.side_effect = ConnectionError() | |||
| original_count = Document.objects.count() | |||
| connection_error = False | |||
| try: | |||
| file = SimpleUploadedFile('file.txt', b'this is some content') | |||
| test = Document() | |||
| test.file = file | |||
| test.save() | |||
| except ConnectionError: | |||
| connection_error = True | |||
| self.assertTrue(connection_error) | |||
| self.assertEqual(Document.objects.count(), original_count) | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage.get_available_name') | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage._save') | |||
| def test_storage_file_name_match(self, mock_redis_an, mock_redis_save): | |||
| mock_redis_an.return_value = 'file.txt' | |||
| mock_redis_save.return_value = 'file.txt' | |||
| file = SimpleUploadedFile('file.txt', b'this is some content') | |||
| test = Document() | |||
| test.file = file | |||
| test.save() | |||
| self.assertTrue(mock_redis_save.called) | |||
| self.assertEqual(test.filename, test.file.name) | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage.get_available_name') | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage._save') | |||
| def test_storage_redis_storage(self, mock_redis_an, mock_redis_save): | |||
| mock_redis_an.return_value = '6061bebb-f2be-4a74-8757-c4063f6f6993_file_txt' | |||
| mock_redis_save.return_value = 'file.txt' | |||
| file = SimpleUploadedFile('file.txt', b'this is some content') | |||
| test = Document() | |||
| test.file = file | |||
| test.save() | |||
| self.assertTrue(mock_redis_save.called) | |||
| self.assertEqual(Document.objects.count(), 1) | |||
| test = Document.objects.get(id=test.id) | |||
| self.assertEqual(test.filename, 'file.txt') | |||
| self.assertNotEqual(test.file.name, 'file.txt') | |||
| def test_storage_redis_key(self): | |||
| name = 'file.txt' | |||
| self.assertNotEqual(generate_unique_filename(None, name), name) | |||
| name = '../../../etc/passwd' | |||
| self.assertNotEqual(generate_unique_filename(None, name), name) | |||
| self.assertFalse('../../' in generate_unique_filename(None, name)) | |||
| name = '../../../etc/passwd%00.png' | |||
| self.assertNotEqual(generate_unique_filename(None, name), name) | |||
| self.assertFalse('../../' in generate_unique_filename(None, name)) | |||
| name = '..%2F..%2F..%2Fetc%2F' | |||
| self.assertNotEqual(generate_unique_filename(None, name), name) | |||
| self.assertFalse('../../' in generate_unique_filename(None, name)) | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage.get_available_name') | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage._save') | |||
| @mock.patch('edivorce.apps.core.redis.RedisStorage.delete') | |||
| def test_storage_redis_delete(self, mock_redis_an, mock_redis_save, mock_redis_delete): | |||
| mock_redis_an.return_value = '6061bebb-f2be-4a74-8757-c4063f6f6993_file_txt' | |||
| mock_redis_save.return_value = 'file.txt' | |||
| mock_redis_delete.return_value = True | |||
| file = SimpleUploadedFile('file.txt', b'this is some content') | |||
| test = Document() | |||
| test.file = file | |||
| test.save() | |||
| test.delete() | |||
| self.assertTrue(mock_redis_delete.called) | |||
| @ -1,23 +0,0 @@ | |||
| from django.shortcuts import render | |||
| from django.views.generic.edit import FormView | |||
| from django import forms | |||
| from ..validators import file_scan_validation | |||
| """ | |||
| Everything in this file is considered as proof of concept work and should not be used for production code. | |||
| """ | |||
| class UploadForm(forms.Form): | |||
| upload_file = forms.FileField(validators=[file_scan_validation]) | |||
| class UploadScan(FormView): | |||
| form_class = UploadForm | |||
| template_name = "poc/upload.html" | |||
| def form_valid(self, form): | |||
| context = self.get_context_data() | |||
| context['validation_success'] = True | |||
| return render(self.request, self.template_name, context) | |||
| @ -0,0 +1,22 @@ | |||
| # Generated by Django 2.2.15 on 2020-08-23 05:10 | |||
| from django.db import migrations, models | |||
| import edivorce.apps.core.redis | |||
| class Migration(migrations.Migration): | |||
| initial = True | |||
| dependencies = [ | |||
| ] | |||
| operations = [ | |||
| migrations.CreateModel( | |||
| name='Document', | |||
| fields=[ | |||
| ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | |||
| ('file', models.FileField(storage=edivorce.apps.core.redis.RedisStorage, upload_to='docs_%Y_%m_%d_%H_%M_%S')), | |||
| ], | |||
| ), | |||
| ] | |||
| @ -0,0 +1,24 @@ | |||
| # Generated by Django 2.2.15 on 2020-08-23 06:06 | |||
| from django.db import migrations, models | |||
| import edivorce.apps.core.redis | |||
| class Migration(migrations.Migration): | |||
| dependencies = [ | |||
| ('poc', '0001_initial'), | |||
| ] | |||
| operations = [ | |||
| migrations.AddField( | |||
| model_name='document', | |||
| name='filename', | |||
| field=models.CharField(max_length=128, null=True), | |||
| ), | |||
| migrations.AlterField( | |||
| model_name='document', | |||
| name='file', | |||
| field=models.FileField(storage=edivorce.apps.core.redis.RedisStorage(), upload_to=edivorce.apps.core.redis.generate_unique_filename), | |||
| ), | |||
| ] | |||
| @ -0,0 +1,27 @@ | |||
| from django.db import models | |||
| from django.conf import settings | |||
| from edivorce.apps.core import redis | |||
| class Document(models.Model): | |||
| """ | |||
| This is only a POC model and should not be loaded on a production system. | |||
| """ | |||
| filename = models.CharField(max_length=128, null=True) # saving the original filename separately | |||
| file = models.FileField(upload_to=redis.generate_unique_filename, storage=redis.RedisStorage()) | |||
| def save(self, *args, **kwargs): | |||
| self.filename = self.file.name | |||
| super(Document, self).save(*args, **kwargs) | |||
| def delete(self, **kwargs): | |||
| """ | |||
| Override delete so we can delete the Redis object when this instance is deleted. | |||
| :param kwargs: | |||
| :return: | |||
| """ | |||
| self.file.delete(save=False) | |||
| super(Document, self).delete(**kwargs) | |||
| @ -0,0 +1,15 @@ | |||
| {% load step_order %} | |||
| {% load load_json %} | |||
| <div class="col-flex progress-column"> | |||
| <h4>POC</h4> | |||
| <a href="{% url 'poc-scan' %}" class="progress-question"> | |||
| <span class="progress-icon"><i class="fa fa-share-alt" aria-hidden="true"></i></span> | |||
| <span class="progress-content">File Scanning</span> | |||
| </a> | |||
| <a href="{% url 'poc-storage' %}" class="progress-question"> | |||
| <span class="progress-icon"><i class="fa fa-info" aria-hidden="true"></i></span> | |||
| <span class="progress-content">Redis Storage</span> | |||
| </a> | |||
| </div> | |||
| @ -0,0 +1,27 @@ | |||
| {% extends 'base.html' %} | |||
| {% load input_field %} | |||
| {% load step_order %} | |||
| {% load load_json %} | |||
| {% block title %}{{ block.super }}: POC{% endblock %} | |||
| {% block progress %}{% include "poc-sidebar.html" %}{% endblock %} | |||
| {% block content %} | |||
| <form method="post">{% csrf_token %} | |||
| <p>Are you sure you want to delete "{{ object }}"?</p> | |||
| <input type="submit" value="Confirm"> | |||
| </form> | |||
| {% endblock %} | |||
| {% block formbuttons %} | |||
| {% endblock %} | |||
| {% block sidebarNav %} | |||
| <!-- no sidebar --> | |||
| {% endblock %} | |||
| {% block sidebar %} | |||
| <!-- no sidebar --> | |||
| {% endblock %} | |||
| @ -0,0 +1,72 @@ | |||
| {% extends 'base.html' %} | |||
| {% load input_field %} | |||
| {% load step_order %} | |||
| {% load load_json %} | |||
| {% block title %}{{ block.super }}: POC{% endblock %} | |||
| {% block progress %}{% include "poc-sidebar.html" %}{% endblock %} | |||
| {% block content %} | |||
| <h1><small>Proof of Concept:</small>File storage</h1> | |||
| <form id="form" method="post" enctype="multipart/form-data"> | |||
| {% csrf_token %} | |||
| <div class="question-well"> | |||
| <h3>Upload a file to store in Redis.</h3> | |||
| <div class="form-group {% if form.file.errors %}has-error{% elif validation_success %}has-success{% endif %}"> | |||
| <div>{{ form.file }}</div> | |||
| {% if form.file.errors %} | |||
| <span class="help-block"> | |||
| {% for err in form.file.errors %}{{ err }}{% endfor %} | |||
| </span> | |||
| {% endif %} | |||
| {% if validation_success %}<span class="help-block">No viruses found</span>{% endif %} | |||
| </div> | |||
| </div> | |||
| <div class="form-buttons clearfix"> | |||
| <button type="submit" class="btn btn-primary pull-right">Submit</button> | |||
| </div> | |||
| <div class="question-well"> | |||
| <h3>Stored documents</h3> | |||
| <table class="table"> | |||
| <thead> | |||
| <tr> | |||
| <th>File name</th> | |||
| <th>Redis key</th> | |||
| <th></th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {% for document in documents %} | |||
| <tr> | |||
| <td class="fact-sheet-question"> | |||
| <a href="{% url "poc-storage-download" document.id %}" target="_blank"> | |||
| {{ document.filename|default:'' }} | |||
| </a> | |||
| </td> | |||
| <td class="fact-sheet-question">{{ document.file.name }}</td> | |||
| <td><a href="{% url "poc-storage-delete" document.id %}" class="delete-link">Delete</a></td> | |||
| </tr> | |||
| {% endfor %} | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| </form> | |||
| {% endblock %} | |||
| {% block formbuttons %} | |||
| {% endblock %} | |||
| {% block sidebarNav %} | |||
| <!-- no sidebar --> | |||
| {% endblock %} | |||
| {% block sidebar %} | |||
| <!-- no sidebar --> | |||
| {% endblock %} | |||
| @ -0,0 +1,11 @@ | |||
| from django.conf.urls import url | |||
| from edivorce.apps.poc import views | |||
| from ..core.decorators import bceid_required | |||
| urlpatterns = [ | |||
| url(r'scan', bceid_required(views.UploadScan.as_view()), name="poc-scan"), | |||
| url(r'storage/doc/(?P<document_id>\d+)', bceid_required(views.view_document_file), name="poc-storage-download"), | |||
| url(r'storage/delete/(?P<pk>\d+)', bceid_required(views.UploadStorageDelete.as_view()), name="poc-storage-delete"), | |||
| url(r'storage', bceid_required(views.UploadStorage.as_view()), name="poc-storage"), | |||
| ] | |||
| @ -0,0 +1,52 @@ | |||
| from django.shortcuts import render | |||
| from django.views.generic.edit import FormView, CreateView, DeleteView | |||
| from django import forms | |||
| from django.http import HttpResponse | |||
| from edivorce.apps.core.validators import file_scan_validation | |||
| from edivorce.apps.poc.models import Document | |||
| """ | |||
| Everything in this file is considered as proof of concept work and should not be used for production code. | |||
| """ | |||
| class UploadScanForm(forms.Form): | |||
| upload_file = forms.FileField(validators=[file_scan_validation]) | |||
| class UploadScan(FormView): | |||
| form_class = UploadScanForm | |||
| template_name = "scan.html" | |||
| def form_valid(self, form): | |||
| context = self.get_context_data() | |||
| context['validation_success'] = True | |||
| return render(self.request, self.template_name, context) | |||
| class UploadStorage(CreateView): | |||
| model = Document | |||
| fields = ['file'] | |||
| template_name = "storage.html" | |||
| success_url = '/poc/storage' | |||
| def get_context_data(self, **kwargs): | |||
| kwargs['documents'] = Document.objects.all() | |||
| return super(UploadStorage, self).get_context_data(**kwargs) | |||
| class UploadStorageDelete(DeleteView): | |||
| model = Document | |||
| success_url = '/poc/storage' | |||
| def view_document_file(request, document_id): | |||
| doc = Document.objects.get(id=document_id) | |||
| content_type = 'application/pdf' if 'pdf' in doc.file.name else 'image/jpeg' | |||
| response = HttpResponse(doc.file.read(), content_type=content_type) | |||
| response['Content-Disposition'] = 'attachment; filename={}'.format(doc.filename) | |||
| return response | |||