diff --git a/.circleci/config.yml b/.circleci/config.yml index c7e509566aa9c0e0e10654b6b9c6ff7a49a98a18..5d1ba29917f4ab96e42e18e0a1bb8b3e8bcf83cc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,7 +13,6 @@ jobs: PGUSER: root DJANGO_SETTINGS_MODULE: opentech.settings.test SEND_MESSAGES: false - PROJECTS_ENABLED: true - image: circleci/postgres:10.5 environment: diff --git a/.travis.yml b/.travis.yml index d2e18cf9fb607c1b6fb6e0318197728cc780363d..3a9983e245c3b1b21cb284eeedf40771ddd90916 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,6 @@ env: global: - DJANGO_SETTINGS_MODULE=opentech.settings.test - DATABASE_URL=postgres://postgres@localhost/test_db - - PROJECTS_ENABLED=true before_script: # Create a database diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py index 635d5754f3e5bb2561ce04ee8850ef5e9b3b8533..cef3242a63812435a66d4ae7fbfce504f9f9c957 100644 --- a/opentech/apply/activity/templatetags/activity_tags.py +++ b/opentech/apply/activity/templatetags/activity_tags.py @@ -12,7 +12,7 @@ register = template.Library() @register.filter def display_author(activity, user): - if activity.source.user == user and isinstance(activity.related_object, Review): + if isinstance(activity.related_object, Review) and activity.source.user == user: return 'Reviewer' return activity.user diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index e9b664b9551d09e446cd7de5bf8dd73856fbbb07..c0edaf6b7abe782220d0d241b14e1e2eeddfb64d 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -15,16 +15,19 @@ class AllActivityContextMixin: def get_context_data(self, **kwargs): extra = { 'actions': Activity.actions.select_related( - 'source_content_type', 'user', + ).prefetch_related( + 'source', )[:ACTIVITY_LIMIT], 'comments': Activity.comments.select_related( - 'source_content_type', 'user', + ).prefetch_related( + 'source', )[:ACTIVITY_LIMIT], 'all_activity': Activity.objects.select_related( - 'source_content_type', 'user', + ).prefetch_related( + 'source', )[:ACTIVITY_LIMIT], } return super().get_context_data(**extra, **kwargs) diff --git a/opentech/apply/determinations/tests/test_views.py b/opentech/apply/determinations/tests/test_views.py index 9adff7a7f4139ab3f621dcd4a80078f5df312036..070836f7630d109033cdfcf6ba6d648924acbf07 100644 --- a/opentech/apply/determinations/tests/test_views.py +++ b/opentech/apply/determinations/tests/test_views.py @@ -2,7 +2,7 @@ import urllib from django.contrib.messages.storage.fallback import FallbackStorage from django.contrib.sessions.middleware import SessionMiddleware -from django.test import RequestFactory +from django.test import override_settings, RequestFactory from django.urls import reverse_lazy from opentech.apply.activity.models import Activity @@ -88,6 +88,7 @@ class DeterminationFormTestCase(BaseViewTestCase): response = self.get_page(submission, 'form') self.assertNotContains(response, 'Update ') + @override_settings(PROJECTS_ENABLED=False) def test_can_edit_draft_determination_if_not_lead(self): submission = ApplicationSubmissionFactory(status='in_discussion') determination = DeterminationFactory(submission=submission, author=self.user, accepted=True) @@ -95,6 +96,13 @@ class DeterminationFormTestCase(BaseViewTestCase): self.assertContains(response, 'Approved') self.assertRedirects(response, self.absolute_url(submission.get_absolute_url())) + def test_can_edit_draft_determination_if_not_lead_with_projects(self): + submission = ApplicationSubmissionFactory(status='in_discussion') + determination = DeterminationFactory(submission=submission, author=self.user, accepted=True) + response = self.post_page(submission, {'data': 'value', 'outcome': determination.outcome}, 'form') + self.assertContains(response, 'Approved') + self.assertRedirects(response, self.absolute_url(submission.get_absolute_url())) + def test_sends_message_if_requires_more_info(self): submission = ApplicationSubmissionFactory(status='in_discussion', lead=self.user) determination = DeterminationFactory(submission=submission, author=self.user) diff --git a/opentech/apply/determinations/views.py b/opentech/apply/determinations/views.py index bee9a188bf0199a9e07d018d525b00d01385a9f9..5cb56920c88ccff6cad55bbf5f5c8916cd84c654 100644 --- a/opentech/apply/determinations/views.py +++ b/opentech/apply/determinations/views.py @@ -267,13 +267,14 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): if self.submission.accepted_for_funding: project = Project.create_from_submission(self.submission) - messenger( - MESSAGES.CREATED_PROJECT, - request=self.request, - user=self.request.user, - source=project, - related=project.submission, - ) + if project: + messenger( + MESSAGES.CREATED_PROJECT, + request=self.request, + user=self.request.user, + source=project, + related=project.submission, + ) messenger( MESSAGES.DETERMINATION_OUTCOME, diff --git a/opentech/apply/funds/files.py b/opentech/apply/funds/files.py new file mode 100644 index 0000000000000000000000000000000000000000..6f834375decea544af46540a69cd42b1370fae84 --- /dev/null +++ b/opentech/apply/funds/files.py @@ -0,0 +1,27 @@ +import os +from django.urls import reverse + +from opentech.apply.stream_forms.files import StreamFieldFile + + +def generate_submission_file_path(submission_id, field_id, file_name): + path = os.path.join('submission', str(submission_id), str(field_id)) + if file_name.startswith(path): + return file_name + + return os.path.join(path, file_name) + + +class SubmissionStreamFileField(StreamFieldFile): + def generate_filename(self): + return generate_submission_file_path(self.instance.pk, self.field.id, self.name) + + @property + def url(self): + return reverse( + 'apply:submissions:serve_private_media', kwargs={ + 'pk': self.instance.pk, + 'field_id': self.field.id, + 'file_name': self.basename, + } + ) diff --git a/opentech/apply/funds/models/mixins.py b/opentech/apply/funds/models/mixins.py index f4ecd6d4e823d02d600d7e828450f906ddddc0a0..aaaec9c3ad5c2056e02359527ff2e01695a259a6 100644 --- a/opentech/apply/funds/models/mixins.py +++ b/opentech/apply/funds/models/mixins.py @@ -1,7 +1,5 @@ -from django.conf import settings from django.utils.text import mark_safe from django.core.files import File -from django.core.files.storage import get_storage_class from opentech.apply.stream_forms.blocks import ( FileFieldBlock, FormFieldBlock, GroupToggleBlock, ImageFieldBlock, MultiFileFieldBlock @@ -9,21 +7,13 @@ from opentech.apply.stream_forms.blocks import ( from opentech.apply.utils.blocks import SingleIncludeMixin from opentech.apply.stream_forms.blocks import UploadableMediaBlock -from opentech.apply.stream_forms.files import StreamFieldFile +from opentech.apply.utils.storage import PrivateStorage +from ..files import SubmissionStreamFileField __all__ = ['AccessFormData'] -private_file_storage = getattr(settings, 'PRIVATE_FILE_STORAGE', None) -submission_storage_class = get_storage_class(private_file_storage) - -if private_file_storage: - submission_storage = submission_storage_class(is_submission=True) -else: - submission_storage = submission_storage_class() - - class UnusedFieldException(Exception): pass @@ -34,8 +24,9 @@ class AccessFormData: requires: - form_data > jsonfield containing the submitted data - form_fields > streamfield containing the original form fields - """ + stream_file_class = SubmissionStreamFileField + storage_class = PrivateStorage @property def raw_data(self): @@ -54,46 +45,51 @@ class AccessFormData: return data @classmethod - def stream_file(cls, file): + def stream_file(cls, instance, field, file): if not file: return [] - if isinstance(file, StreamFieldFile): + if isinstance(file, cls.stream_file_class): return file if isinstance(file, File): - return StreamFieldFile(file, name=file.name, storage=submission_storage) + return cls.stream_file_class(instance, field, file, name=file.name, storage=cls.storage_class()) # This fixes a backwards compatibility issue with #507 # Once every application has been re-saved it should be possible to remove it if 'path' in file: file['filename'] = file['name'] file['name'] = file['path'] - return StreamFieldFile(None, name=file['name'], filename=file.get('filename'), storage=submission_storage) + return cls.stream_file_class(instance, field, None, name=file['name'], filename=file.get('filename'), storage=cls.storage_class()) @classmethod - def process_file(cls, file): + def process_file(cls, instance, field, file): if isinstance(file, list): - return [cls.stream_file(f) for f in file] + return [cls.stream_file(instance, field, f) for f in file] else: - return cls.stream_file(file) + return cls.stream_file(instance, field, file) @classmethod def from_db(cls, db, field_names, values): instance = super().from_db(db, field_names, values) if 'form_data' in field_names: # When the form_data is loaded from the DB deserialise it - instance.form_data = cls.deserialised_data(instance.form_data, instance.form_fields) + instance.form_data = cls.deserialised_data(instance, instance.form_data, instance.form_fields) return instance @classmethod - def deserialised_data(cls, data, form_fields): + def deserialised_data(cls, instance, data, form_fields): # Converts the file dicts into actual file objects data = data.copy() - for field in form_fields.stream_data: - block = form_fields.stream_block.child_blocks[field['type']] + # PERFORMANCE NOTE: + # Do not attempt to iterate over form_fields - that will fully instantiate the form_fields + # including any sub queries that they do + for i, field_data in enumerate(form_fields.stream_data): + block = form_fields.stream_block.child_blocks[field_data['type']] if isinstance(block, UploadableMediaBlock): - field_id = field.get('id') - file = data.get(field_id, []) - data[field_id] = cls.process_file(file) + field_id = field_data.get('id') + if field_id: + field = form_fields[i] + file = data.get(field_id, []) + data[field_id] = cls.process_file(instance, field, file) return data def get_definitive_id(self, id): @@ -123,17 +119,25 @@ class AccessFormData: yield field_id @property - def question_text_field_ids(self): + def file_field_ids(self): for field_id, field in self.fields.items(): if isinstance(field.block, (FileFieldBlock, ImageFieldBlock, MultiFileFieldBlock)): + yield field_id + + @property + def question_text_field_ids(self): + file_fields = list(self.file_field_ids) + for field_id, field in self.fields.items(): + if field_id in file_fields: pass elif isinstance(field.block, FormFieldBlock): yield field_id @property def first_group_question_text_field_ids(self): + file_fields = list(self.file_field_ids) for field_id, field in self.fields.items(): - if isinstance(field.block, (FileFieldBlock, ImageFieldBlock, MultiFileFieldBlock)): + if field_id in file_fields: continue elif isinstance(field.block, GroupToggleBlock): break diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py index 6a5daa08fe7ecf87d436a23631afb91dba1ae2e3..008c3a5dc8bcec8f7758f29ba38bc3ba147af404 100644 --- a/opentech/apply/funds/models/submissions.py +++ b/opentech/apply/funds/models/submissions.py @@ -1,4 +1,3 @@ -import os from functools import partialmethod from django.conf import settings @@ -201,8 +200,10 @@ class ApplicationSubmissionQueryset(JSONOrderable): ).prefetch_related( Prefetch( 'assigned', - queryset=AssignedReviewers.objects.reviewed().review_order().prefetch_related( - Prefetch('opinions', queryset=ReviewOpinion.objects.select_related('author__reviewer')) + queryset=AssignedReviewers.objects.reviewed().review_order().select_related( + 'reviewer', + ).prefetch_related( + Prefetch('review__opinions', queryset=ReviewOpinion.objects.select_related('author')), ), to_attr='has_reviewed' ), @@ -211,7 +212,6 @@ class ApplicationSubmissionQueryset(JSONOrderable): queryset=AssignedReviewers.objects.not_reviewed().staff(), to_attr='hasnt_reviewed' ) - ).select_related( 'page', 'round', @@ -219,6 +219,7 @@ class ApplicationSubmissionQueryset(JSONOrderable): 'user', 'previous__page', 'previous__round', + 'previous__lead', ) @@ -538,7 +539,7 @@ class ApplicationSubmission( def from_draft(self): self.is_draft = True - self.form_data = self.deserialised_data(self.draft_revision.form_data, self.form_fields) + self.form_data = self.deserialised_data(self, self.draft_revision.form_data, self.form_fields) return self def create_revision(self, draft=False, force=False, by=None, **kwargs): @@ -587,13 +588,12 @@ class ApplicationSubmission( def process_file_data(self, data): for field in self.form_fields: if isinstance(field.block, UploadableMediaBlock): - file = self.process_file(data.get(field.id, [])) - folder = os.path.join('submission', str(self.id), field.id) + file = self.process_file(self, field, data.get(field.id, [])) try: - file.save(folder) + file.save() except AttributeError: for f in file: - f.save(folder) + f.save() self.form_data[field.id] = file def save(self, *args, update_fields=list(), **kwargs): diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html index 2216c7cfc8e3a3a7ce11c3a373583fb3ce9ec754..fbb20c4b1e135d2cb2e90e50756d418ecaf79bbf 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -17,7 +17,7 @@ {% include "funds/includes/update_lead_form.html" %} {% include "funds/includes/update_reviewer_form.html" %} {% include "funds/includes/update_partner_form.html" %} - {% if settings.PROJECTS_ENABLED %} + {% if PROJECTS_ENABLED %} {% include "funds/includes/create_project_form.html" %} {% endif %} {% include "funds/includes/update_meta_categories_form.html" %} diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html index 077f4ae6edf893c78a9d506c59426a52dcfaaef3..7ac1ea17de1c00aaa804f633313a88bc0391d615 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html @@ -99,7 +99,7 @@ {% endblock %} {% endif %} - {% if object.project and settings.PROJECTS_ENABLED %} + {% if object.project and PROJECTS_ENABLED %} {% include 'funds/includes/project_block.html' %} {% endif %} diff --git a/opentech/apply/funds/templates/funds/submissions.html b/opentech/apply/funds/templates/funds/submissions.html index ca70880476eaac1808566d08c7b7a7c21e54ef21..353450fa78d785a9339a12023d7eb3f69c5f3736 100644 --- a/opentech/apply/funds/templates/funds/submissions.html +++ b/opentech/apply/funds/templates/funds/submissions.html @@ -14,11 +14,6 @@ </div> <div class="wrapper wrapper--large wrapper--inner-space-medium"> - - {% if closed_rounds or open_rounds %} - {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title %} - {% endif %} - {% block table %} {{ block.super }} {% endblock %} diff --git a/opentech/apply/funds/tests/test_views.py b/opentech/apply/funds/tests/test_views.py index 55b5d4119bbf414e13fe80d8921681f84fc2e49f..2d7918c3e7b4bc4bad44d1d73c709c210195e22d 100644 --- a/opentech/apply/funds/tests/test_views.py +++ b/opentech/apply/funds/tests/test_views.py @@ -1,9 +1,11 @@ from datetime import timedelta import json +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied from django.http import Http404 from django.test import RequestFactory, TestCase +from django.urls import reverse from django.utils import timezone from django.utils.text import slugify @@ -694,3 +696,56 @@ class TestSubmissionDetailSimplifiedView(TestCase): ProjectFactory(submission=submission) response = SubmissionDetailSimplifiedView.as_view()(request, pk=submission.pk) self.assertEqual(response.status_code, 200) + + +class BaseSubmissionFileViewTestCase(BaseViewTestCase): + url_name = 'funds:submissions:{}' + base_view_name = 'serve_private_media' + + def get_kwargs(self, instance): + document_fields = list(instance.file_field_ids) + field_id = document_fields[0] + document = instance.data(field_id) + return { + 'pk': instance.pk, + 'field_id': field_id, + 'file_name': document.basename, + } + + +class TestStaffSubmissionFileView(BaseSubmissionFileViewTestCase): + user_factory = StaffFactory + + def test_staff_can_access(self): + submission = ApplicationSubmissionFactory() + response = self.get_page(submission) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + +class TestUserSubmissionFileView(BaseSubmissionFileViewTestCase): + user_factory = ApplicantFactory + + def test_owner_can_access(self): + submission = ApplicationSubmissionFactory(user=self.user) + response = self.get_page(submission) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_user_can_not_access(self): + submission = ApplicationSubmissionFactory() + response = self.get_page(submission) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.redirect_chain, []) + + +class TestAnonSubmissionFileView(BaseSubmissionFileViewTestCase): + user_factory = AnonymousUser + + def test_anonymous_can_not_access(self): + submission = ApplicationSubmissionFactory() + response = self.get_page(submission) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 2) + for path, _ in response.redirect_chain: + self.assertIn(reverse('users_public:login'), path) diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 3cb33249fa79b4baa48d9a365a3a1a9a6c4241a7..706fddede0349dcf01515894b85a659f4e23e46e 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from opentech.apply.projects.urls import urlpatterns as projects_urls +from opentech.apply.projects import urls as projects_urls from .views import ( RevisionCompareView, @@ -40,16 +40,16 @@ app_name = 'funds' submission_urls = ([ path('', SubmissionOverviewView.as_view(), name="overview"), path('all/', SubmissionListView.as_view(), name="list"), - path( - 'documents/submission/<int:submission_id>/<uuid:field_id>/<str:file_name>/', - SubmissionPrivateMediaView.as_view(), name='serve_private_media' - ), path('<int:pk>/', include([ path('', SubmissionDetailView.as_view(), name="detail"), path('edit/', SubmissionEditView.as_view(), name="edit"), path('sealed/', SubmissionSealedView.as_view(), name="sealed"), path('simplified/', SubmissionDetailSimplifiedView.as_view(), name="simplified"), path('delete/', SubmissionDeleteView.as_view(), name="delete"), + path( + 'documents/<uuid:field_id>/<str:file_name>', + SubmissionPrivateMediaView.as_view(), name='serve_private_media' + ), ])), path('<int:submission_pk>/', include([ path('', include('opentech.apply.review.urls', namespace="reviews")), @@ -85,6 +85,6 @@ rounds_urls = ([ urlpatterns = [ path('submissions/', include(submission_urls)), path('rounds/', include(rounds_urls)), - path('projects/', include((projects_urls, 'projects'))), + path('projects/', include(projects_urls)), path('api/', include(api_urls)), ] diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index 2553e7a60a655c622b91d98f98253a49ac13ed18..43922b73365c26c2324bb654821934e8999c9786 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -1,22 +1,17 @@ -import mimetypes from copy import copy -from wsgiref.util import FileWrapper -from django.conf import settings from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import UserPassesTestMixin -from django.contrib.auth.views import redirect_to_login from django.contrib import messages from django.core.exceptions import PermissionDenied -from django.core.files.storage import get_storage_class from django.db.models import Count, F, Q -from django.http import HttpResponseRedirect, Http404, StreamingHttpResponse +from django.http import HttpResponseRedirect, Http404 from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.utils.text import mark_safe from django.utils.translation import ugettext_lazy as _ -from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView, DeleteView, View +from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView, DeleteView from django_filters.views import FilterView from django_tables2.views import SingleTableMixin @@ -35,9 +30,11 @@ from opentech.apply.projects.forms import CreateProjectForm from opentech.apply.projects.models import Project from opentech.apply.review.views import ReviewContextMixin from opentech.apply.users.decorators import staff_required +from opentech.apply.utils.storage import PrivateMediaView from opentech.apply.utils.views import DelegateableListView, DelegateableView, ViewDispatcher from .differ import compare +from .files import generate_submission_file_path from .forms import ( BatchUpdateSubmissionLeadForm, BatchUpdateReviewersForm, @@ -56,6 +53,7 @@ from .models import ( RoundBase, LabBase ) +from .permissions import is_user_has_access_to_view_submission from .tables import ( AdminSubmissionsTable, ReviewerSubmissionsTable, @@ -66,9 +64,6 @@ from .tables import ( SummarySubmissionsTable, ) from .workflow import INITIAL_STATE, STAGE_CHANGE_ACTIONS, PHASES_MAPPING, review_statuses -from .permissions import is_user_has_access_to_view_submission - -submission_storage = get_storage_class(getattr(settings, 'PRIVATE_FILE_STORAGE', None))() class BaseAdminSubmissionsTable(SingleTableMixin, FilterView): @@ -917,48 +912,23 @@ class SubmissionDeleteView(DeleteView): return response -class SubmissionPrivateMediaView(UserPassesTestMixin, View): +@method_decorator(login_required, name='dispatch') +class SubmissionPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + submission_pk = self.kwargs['pk'] + self.submission = get_object_or_404(ApplicationSubmission, pk=submission_pk) + return super().dispatch(*args, **kwargs) - def get(self, *args, **kwargs): - submission_id = kwargs['submission_id'] + def get_media(self, *args, **kwargs): field_id = kwargs['field_id'] file_name = kwargs['file_name'] - file_name_with_path = f'submission/{submission_id}/{field_id}/{file_name}' - - submission_file = submission_storage.open(file_name_with_path) - wrapper = FileWrapper(submission_file) - encoding_map = { - 'bzip2': 'application/x-bzip', - 'gzip': 'application/gzip', - 'xz': 'application/x-xz', - } - content_type, encoding = mimetypes.guess_type(file_name) - # Encoding isn't set to prevent browsers from automatically uncompressing files. - content_type = encoding_map.get(encoding, content_type) - content_type = content_type or 'application/octet-stream' - # From Django 2.1, we can use FileResponse instead of StreamingHttpResponse - response = StreamingHttpResponse(wrapper, content_type=content_type) - - response['Content-Disposition'] = f'filename={file_name}' - response['Content-Length'] = submission_file.size - - return response + path_to_file = generate_submission_file_path(self.submission.pk, field_id, file_name) + return self.storage.open(path_to_file) def test_func(self): - submission_id = self.kwargs['submission_id'] - submission = get_object_or_404(ApplicationSubmission, id=submission_id) - - return is_user_has_access_to_view_submission(self.request.user, submission) - - def handle_no_permission(self): - # This method can be removed after upgrading Django to 2.1 - # https://github.com/django/django/commit/9b1125bfc7e2dc747128e6e7e8a2259ff1a7d39f - # In older versions, authenticated users who lacked permissions were - # redirected to the login page (which resulted in a loop) instead of - # receiving an HTTP 403 Forbidden response. - if self.raise_exception or self.request.user.is_authenticated: - raise PermissionDenied(self.get_permission_denied_message()) - return redirect_to_login(self.request.get_full_path(), self.get_login_url(), self.get_redirect_field_name()) + return is_user_has_access_to_view_submission(self.request.user, self.submission) @method_decorator(staff_required, name='dispatch') diff --git a/opentech/apply/projects/context_processors.py b/opentech/apply/projects/context_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..b04840afcfa1bc16f139ec39dcac52e4da541387 --- /dev/null +++ b/opentech/apply/projects/context_processors.py @@ -0,0 +1,5 @@ +from django.conf import settings + + +def projects_enabled(request): + return {'PROJECTS_ENABLED': settings.PROJECTS_ENABLED} diff --git a/opentech/apply/projects/migrations/0012_adjust_storage_class.py b/opentech/apply/projects/migrations/0012_adjust_storage_class.py new file mode 100644 index 0000000000000000000000000000000000000000..7834f8d2758b480be70a754dab1375eea9b50bb1 --- /dev/null +++ b/opentech/apply/projects/migrations/0012_adjust_storage_class.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.13 on 2019-08-10 09:54 + +import django.core.files.storage +from django.db import migrations, models +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0011_add_packet_file'), + ] + + operations = [ + migrations.AlterField( + model_name='packetfile', + name='document', + field=models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.document_path), + ), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index e7c1dd8a939002928796dbf1989d93c5d303c709..8e014e08696ee5987d67fdcee95175a5fff3533b 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -12,6 +12,9 @@ from django.db import models from django.urls import reverse from django.utils.translation import ugettext as _ +from opentech.apply.utils.storage import PrivateStorage + + logger = logging.getLogger(__name__) @@ -55,7 +58,7 @@ class PacketFile(models.Model): project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="packet_files") title = models.TextField() - document = models.FileField(upload_to=document_path) + document = models.FileField(upload_to=document_path, storage=PrivateStorage()) def __str__(self): return f'Project file: {self.title}' @@ -183,7 +186,9 @@ class Project(models.Model): return self.lead and not self.is_locked def get_absolute_url(self): - return reverse('apply:projects:detail', args=[self.id]) + if settings.PROJECTS_ENABLED: + return reverse('apply:projects:detail', args=[self.id]) + return '#' @property def can_make_approval(self): diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html index f0da56dcfbf0ada7d37b5813c628903080fdb6f4..8e3e1acc21c727fc912a6082bbc4aa8143343eba 100644 --- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -75,7 +75,7 @@ <p class="docs-block__document-info">{{ document.category.name }}</p> </div> <div class="docs-block__document-inner"> - <a class="docs-block__document-link" href="{{ document.document.url }}">Download</a> + <a class="docs-block__document-link" href="{% url 'apply:projects:document' pk=object.pk file_pk=document.pk %}">Download</a> <form method="POST" id="{{ remove_document_form.name }}"> {% csrf_token %} {{ document.get_remove_form }} diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index ac577d5e03b1b70fb7c03a42ec1c851f646fe7f1..7f54fcc2678b2a272a423261e51ca336986007cb 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -1,6 +1,8 @@ from io import BytesIO +from django.contrib.auth.models import AnonymousUser from django.test import TestCase +from django.urls import reverse from opentech.apply.funds.tests.factories import LabSubmissionFactory from opentech.apply.users.tests.factories import ( @@ -550,3 +552,52 @@ class TestApproveContractView(BaseViewTestCase): messages = list(response.context['messages']) self.assertEqual(len(messages), 1) + + +class BasePacketFileViewTestCase(BaseViewTestCase): + url_name = 'funds:projects:{}' + base_view_name = 'document' + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'file_pk': instance.id, + } + + +class TestStaffPacketView(BasePacketFileViewTestCase): + user_factory = StaffFactory + + def test_staff_can_access(self): + document = PacketFileFactory() + response = self.get_page(document) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + +class TestUserPacketView(BasePacketFileViewTestCase): + user_factory = ApplicantFactory + + def test_owner_can_access(self): + document = PacketFileFactory(project__user=self.user) + response = self.get_page(document) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_user_can_not_access(self): + document = PacketFileFactory() + response = self.get_page(document) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.redirect_chain, []) + + +class TestAnonPacketView(BasePacketFileViewTestCase): + user_factory = AnonymousUser + + def test_anonymous_can_not_access(self): + document = PacketFileFactory() + response = self.get_page(document) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.redirect_chain), 2) + for path, _ in response.redirect_chain: + self.assertIn(reverse('users_public:login'), path) diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index cad1a6080d33c000514c1013008f3c19550a9381..1b2f625fda11c5b784ba16ab0267023f4b0373c4 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -1,7 +1,15 @@ from django.conf import settings from django.urls import include, path -from .views import ApproveContractView, ProjectDetailView, ProjectEditView +from .views import ( + AppriveContractView, + ProjectDetailView, + ProjectEditView, + ProjectPrivateMediaView +) + + +app_name = 'projects' urlpatterns = [] @@ -15,5 +23,6 @@ if settings.PROJECTS_ENABLED: ApproveContractView.as_view(), name="approve-contract", ), + path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"), ])), ] diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py index 007ba010bda5322ff74733452c378451ce4716c2..b7d5f5fde838c8c13f84fca4af41ec7b274318cd 100644 --- a/opentech/apply/projects/views.py +++ b/opentech/apply/projects/views.py @@ -2,8 +2,10 @@ from copy import copy from django.contrib import messages from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.db import transaction +from django.http import Http404 from django.shortcuts import get_object_or_404, redirect from django.utils.decorators import method_decorator from django.utils.translation import ugettext_lazy as _ @@ -12,10 +14,11 @@ from django.views.generic import CreateView, DetailView, FormView, UpdateView from opentech.apply.activity.messaging import MESSAGES, messenger from opentech.apply.activity.views import ActivityContextMixin, CommentFormView from opentech.apply.users.decorators import staff_required +from opentech.apply.utils.storage import PrivateMediaView from opentech.apply.utils.views import ( DelegateableView, DelegatedViewMixin, - ViewDispatcher + ViewDispatcher, ) from .forms import ( @@ -314,6 +317,31 @@ class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, Contrac return super().dispatch(request, *args, **kwargs) +@method_decorator(login_required, name='dispatch') +class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + project_pk = self.kwargs['pk'] + self.project = get_object_or_404(Project, pk=project_pk) + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + document = PacketFile.objects.get(pk=kwargs['file_pk']) + if document.project != self.project: + raise Http404 + return document.document + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user == self.project.user: + return True + + return False + + class ProjectDetailView(ViewDispatcher): admin_view = AdminProjectDetailView applicant_view = ApplicantProjectDetailView diff --git a/opentech/apply/stream_forms/files.py b/opentech/apply/stream_forms/files.py index 8fdf2febd2906c7baf65f19efe2f9dcb45db596e..214b12c2623b31eb94be9113520503b8809559fb 100644 --- a/opentech/apply/stream_forms/files.py +++ b/opentech/apply/stream_forms/files.py @@ -16,15 +16,28 @@ class StreamFieldDataEncoder(DjangoJSONEncoder): class StreamFieldFile(File): - def __init__(self, *args, filename=None, storage=default_storage, **kwargs): + """ + Attempts to mimic the behaviour of the bound fields in django models + + see django.db.models.fields.files for the inspiration + """ + def __init__(self, instance, field, *args, filename=None, storage=default_storage, **kwargs): super().__init__(*args, **kwargs) + # Field is the wagtail field that the file was uploaded to + self.field = field + # Instance is the parent model object that created this file object + self.instance = instance self.storage = storage - self.filename = filename or os.path.basename(self.name) + self.filename = filename or self.basename self._committed = False def __str__(self): return self.filename + @property + def basename(self): + return os.path.basename(self.name) + def __eq__(self, other): if isinstance(other, File): return self.filename == other.filename and self.size == other.size @@ -79,10 +92,11 @@ class StreamFieldFile(File): self.file.open(mode) return self - def save(self, folder): - name = self.name - if not name.startswith(folder): - name = os.path.join(folder, name) + def generate_filename(self): + return self.name + + def save(self): + name = self.generate_filename() name = self.storage.generate_filename(name) if not self._committed: self.name = self.storage.save(name, self.file) diff --git a/opentech/apply/stream_forms/tests.py b/opentech/apply/stream_forms/tests.py index a8d37d02de09a7a503431f897d4c7299db54e86a..2a861023ffd8f1b6bb9d7173b2b00c0ed85a0a48 100644 --- a/opentech/apply/stream_forms/tests.py +++ b/opentech/apply/stream_forms/tests.py @@ -12,7 +12,7 @@ fake = Faker() def make_files(number): file_names = [f'{fake.word()}_{i}.pdf' for i in range(number)] files = [ - StreamFieldFile(SimpleUploadedFile(name, b'Some File Content'), filename=name) + StreamFieldFile('fake', 'uuid', SimpleUploadedFile(name, b'Some File Content'), filename=name) for name in file_names ] return files diff --git a/opentech/apply/utils/image.py b/opentech/apply/utils/image.py index 5086fb3f658fbab73318be5b8804b40dacd9e3df..93cfdc5fb13089cedc9ea822601c2a3dcf0cef31 100644 --- a/opentech/apply/utils/image.py +++ b/opentech/apply/utils/image.py @@ -1,12 +1,22 @@ +from django.core.cache import cache from django.urls import reverse from django.utils.html import format_html +def image_url_cache_key(image_id, spec): + return f'image_url_cache_{image_id}_{spec}' + + def generate_image_url(image, filter_spec): + cache_key = image_url_cache_key(image.id, filter_spec) + url = cache.get(cache_key) + if url: + return url from wagtail.images.views.serve import generate_signature signature = generate_signature(image.id, filter_spec) url = reverse('wagtailimages_serve', args=(signature, image.id, filter_spec)) url += image.file.name[len('original_images/'):] + cache.set(cache_key, url) return url diff --git a/opentech/apply/utils/storage.py b/opentech/apply/utils/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..5e257d3598f360b0607cbcb718e8df4eed976cd3 --- /dev/null +++ b/opentech/apply/utils/storage.py @@ -0,0 +1,41 @@ +import mimetypes +import os +from wsgiref.util import FileWrapper + +from django.conf import settings +from django.core.files.storage import get_storage_class +from django.http import StreamingHttpResponse +from django.views.generic import View + + +private_file_storage = getattr(settings, 'PRIVATE_FILE_STORAGE', None) +PrivateStorage = get_storage_class(private_file_storage) + + +class PrivateMediaView(View): + storage = PrivateStorage() + + def get_media(self, *args, **kwargs): + # Convert the URL request to a path which the storage can use to find the file + raise NotImplementedError() + + def get(self, *args, **kwargs): + file_to_serve = self.get_media(*args, **kwargs) + wrapper = FileWrapper(file_to_serve) + encoding_map = { + 'bzip2': 'application/x-bzip', + 'gzip': 'application/gzip', + 'xz': 'application/x-xz', + } + file_name = os.path.basename(file_to_serve.name) + content_type, encoding = mimetypes.guess_type(file_name) + # Encoding isn't set to prevent browsers from automatically uncompressing files. + content_type = encoding_map.get(encoding, content_type) + content_type = content_type or 'application/octet-stream' + # From Django 2.1, we can use FileResponse instead of StreamingHttpResponse + response = StreamingHttpResponse(wrapper, content_type=content_type) + + response['Content-Disposition'] = f'filename={file_name}' + response['Content-Length'] = file_to_serve.size + + return response diff --git a/opentech/apply/utils/testing/tests.py b/opentech/apply/utils/testing/tests.py index bba277b3d117141767a458d942b252b9a2cd63b6..2940aceb26b441b25726c68255964da9d9f1db54 100644 --- a/opentech/apply/utils/testing/tests.py +++ b/opentech/apply/utils/testing/tests.py @@ -29,7 +29,8 @@ class BaseViewTestCase(TestCase): super().setUpTestData() def setUp(self): - self.client.force_login(self.user) + if not self.user.is_anonymous: + self.client.force_login(self.user) self.factory = RequestFactory() def get_kwargs(self, instance): diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 6fa7c5852c2f38ecd3906c7fc7d1e5c2bd2a0775..dc741ff71b615039920b2de262272f8361154451 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -182,6 +182,7 @@ TEMPLATES = [ 'opentech.public.utils.context_processors.global_vars', 'social_django.context_processors.backends', 'social_django.context_processors.login_redirect', + 'opentech.apply.projects.context_processors.projects_enabled', ], }, }, diff --git a/opentech/settings/dev.py b/opentech/settings/dev.py index b6fd212bc1fa13d4ba601106914e106ea8906468..01e2301e5edaf0e08dfaff107651eaab54c39f06 100644 --- a/opentech/settings/dev.py +++ b/opentech/settings/dev.py @@ -34,6 +34,8 @@ try: except ImportError: pass +PROJECTS_ENABLED = True + # We add these here so they can react on settings made in local.py. # E-mail to local files. diff --git a/opentech/settings/test.py b/opentech/settings/test.py index 11a71f78e5248d8568b15eaf0f8d0ca00ff70acd..3d6102ba8bff38edc37cef28a368432054de92c1 100644 --- a/opentech/settings/test.py +++ b/opentech/settings/test.py @@ -8,6 +8,8 @@ from .base import * # noqa SECRET_KEY = 'NOT A SECRET' +PROJECTS_ENABLED = True + # Need this to ensure white noise doesn't kill the speed of testing # http://whitenoise.evans.io/en/latest/django.html#whitenoise-makes-my-tests-run-slow WHITENOISE_AUTOREFRESH = True diff --git a/opentech/storage_backends.py b/opentech/storage_backends.py index e7f08555901397cda8232239a2bed3d01b289432..4dba8b95e81bd05ce6dc79e6a4932e2e91d68428 100644 --- a/opentech/storage_backends.py +++ b/opentech/storage_backends.py @@ -1,7 +1,6 @@ from urllib import parse from django.conf import settings -from django.urls import reverse from django.utils.encoding import filepath_to_uri from storages.backends.s3boto3 import S3Boto3Storage @@ -29,21 +28,8 @@ class PrivateMediaStorage(S3Boto3Storage): file_overwrite = False querystring_auth = True url_protocol = 'https:' - is_submission = False def url(self, name, parameters=None, expire=None): - if self.is_submission: - try: - name_parts = name.split('/') - return reverse( - 'apply:submissions:serve_private_media', kwargs={ - 'submission_id': name_parts[1], 'field_id': name_parts[2], - 'file_name': name_parts[3] - } - ) - except IndexError: - pass - url = super().url(name, parameters, expire) if hasattr(settings, 'AWS_PRIVATE_CUSTOM_DOMAIN'):