diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index edb540b2130358a560fcd71ed60f63730b332cca..4b07ca70384ee63ff023ffbfc7a6be86527439ac 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -1,11 +1,11 @@ -from enum import Enum - import requests +from django.db import models from django.conf import settings from django.contrib import messages from django.template.loader import render_to_string +from .options import MESSAGES from .tasks import send_mail @@ -13,27 +13,15 @@ def link_to(target, request): return request.scheme + '://' + request.get_host() + target.get_absolute_url() -class MESSAGES(Enum): - UPDATE_LEAD = 'Update Lead' - EDIT = 'Edit' - APPLICANT_EDIT = "Applicant Edit" - NEW_SUBMISSION = 'New Submission' - TRANSITION = 'Transition' - DETERMINATION_OUTCOME = 'Determination Outcome' - INVITED_TO_PROPOSAL = 'Invited To Proposal' - REVIEWERS_UPDATED = 'Reviewers Updated' - READY_FOR_REVIEW = 'Ready For Review' - NEW_REVIEW = 'New Review' - COMMENT = 'Comment' - PROPOSAL_SUBMITTED = 'Proposal Submitted' - OPENED_SEALED = 'Opened Sealed Submission' - - @classmethod - def choices(cls): - return [ - (choice.name, choice.value) - for choice in cls - ] +neat_related = { + MESSAGES.DETERMINATION_OUTCOME: 'determination', + MESSAGES.UPDATE_LEAD: 'old_lead', + MESSAGES.NEW_REVIEW: 'review', + MESSAGES.TRANSITION: 'old_phase', + MESSAGES.APPLICANT_EDIT: 'revision', + MESSAGES.EDIT: 'revision', + MESSAGES.COMMENT: 'comment', +} class AdapterBase: @@ -62,10 +50,32 @@ class AdapterBase: def extra_kwargs(self, message_type, **kwargs): return {} + def get_neat_related(self, message_type, related): + # We translate the related kwarg into something we can understand + try: + neat_name = neat_related[message_type] + except KeyError: + # Message type doesn't expect a related object + if related: + raise ValueError(f"Unexpected 'related' kwarg provided for {message_type}") from None + return {} + else: + if not related: + raise ValueError(f"{message_type} expects a 'related' kwarg") + return {neat_name: related} + def recipients(self, message_type, **kwargs): raise NotImplementedError() - def process(self, message_type, event, **kwargs): + def process(self, message_type, event, request, user, submission, related=None, **kwargs): + kwargs = { + 'request': request, + 'user': user, + 'submission': submission, + 'related': related, + **kwargs, + } + kwargs.update(self.get_neat_related(message_type, related)) kwargs.update(self.extra_kwargs(message_type, **kwargs)) message = self.message(message_type, **kwargs) @@ -86,7 +96,7 @@ class AdapterBase: message = '{} [to: {}]: {}'.format(self.adapter_type, recipient, message) else: message = '{}: {}'.format(self.adapter_type, message) - messages.add_message(kwargs['request'], messages.INFO, message) + messages.add_message(request, messages.INFO, message) def create_log(self, message, recipient, event): from .models import Message @@ -111,7 +121,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.NEW_SUBMISSION: 'Submitted {submission.title} for {submission.page.title}', MESSAGES.EDIT: 'Edited', MESSAGES.APPLICANT_EDIT: 'Edited', - MESSAGES.UPDATE_LEAD: 'Lead changed from {old.lead} to {submission.lead}', + MESSAGES.UPDATE_LEAD: 'Lead changed from {old_lead} to {submission.lead}', MESSAGES.DETERMINATION_OUTCOME: 'Sent a determination. Outcome: {determination.clean_outcome}', MESSAGES.INVITED_TO_PROPOSAL: 'Invited to submit a proposal', MESSAGES.REVIEWERS_UPDATED: 'reviewers_updated', @@ -123,7 +133,7 @@ class ActivityAdapter(AdapterBase): return [None] def extra_kwargs(self, message_type, **kwargs): - if message_type == MESSAGES.OPENED_SEALED: + if message_type in [MESSAGES.OPENED_SEALED, MESSAGES.REVIEWERS_UPDATED]: from .models import INTERNAL return {'visibility': INTERNAL} return {} @@ -143,11 +153,19 @@ class ActivityAdapter(AdapterBase): def send_message(self, message, user, submission, **kwargs): from .models import Activity, PUBLIC visibility = kwargs.get('visibility', PUBLIC) + + related = kwargs['related'] + if isinstance(related, models.Model): + related_object = related + else: + related_object = None + Activity.actions.create( user=user, submission=submission, message=message, visibility=visibility, + related_object=related_object, ) @@ -156,7 +174,7 @@ class SlackAdapter(AdapterBase): always_send = True messages = { MESSAGES.NEW_SUBMISSION: 'A new submission has been submitted for {submission.page.title}: <{link}|{submission.title}>', - MESSAGES.UPDATE_LEAD: 'The lead of <{link}|{submission.title}> has been updated from {old.lead} to {submission.lead} by {user}', + MESSAGES.UPDATE_LEAD: 'The lead of <{link}|{submission.title}> has been updated from {old_lead} to {submission.lead} by {user}', MESSAGES.COMMENT: 'A new comment has been posted on <{link}|{submission.title}>', MESSAGES.EDIT: '{user} has edited <{link}|{submission.title}>', MESSAGES.APPLICANT_EDIT: '{user} has edited <{link}|{submission.title}>', @@ -286,14 +304,14 @@ class MessengerBackend: def __init__(self, *adpaters): self.adapters = adpaters - def __call__(self, message_type, request, user, submission, **kwargs): - return self.send(message_type, request=request, user=user, submission=submission, **kwargs) + def __call__(self, message_type, request, user, submission, related=None, **kwargs): + return self.send(message_type, request=request, user=user, submission=submission, related=related, **kwargs) - def send(self, message_type, user, submission, **kwargs): + def send(self, message_type, request, user, submission, related, **kwargs): from .models import Event event = Event.objects.create(type=message_type.name, by=user, submission=submission) for adapter in self.adapters: - adapter.process(message_type, event, user=user, submission=submission, **kwargs) + adapter.process(message_type, event, request=request, user=user, submission=submission, related=related, **kwargs) adapters = [ diff --git a/opentech/apply/activity/migrations/0012_add_generic_relation_to_activity.py b/opentech/apply/activity/migrations/0012_add_generic_relation_to_activity.py new file mode 100644 index 0000000000000000000000000000000000000000..be9a21f8ab9e406dc485c86033e304fee85cdeb2 --- /dev/null +++ b/opentech/apply/activity/migrations/0012_add_generic_relation_to_activity.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.2 on 2018-09-06 09:43 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('activity', '0011_add_new_event_type'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index 4ae6c0efc4b9d0969e0c16119e0f63196a5eb0e1..cabc295831be94bc29954fe864d51fe2e33e99ae 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -1,9 +1,11 @@ from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Case, When, Value from django.db.models.functions import Concat -from .messaging import MESSAGES +from .options import MESSAGES COMMENT = 'comment' @@ -81,6 +83,11 @@ class Activity(models.Model): message = models.TextField() visibility = models.CharField(choices=VISIBILITY.items(), default=PUBLIC, max_length=10) + # Fields for generic relations to other objects. related_object should implement `get_absolute_url` + content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(blank=True, null=True) + related_object = GenericForeignKey('content_type', 'object_id') + objects = models.Manager.from_queryset(ActivityQuerySet)() comments = CommentManger.from_queryset(CommentQueryset)() actions = ActionManager.from_queryset(ActionQueryset)() diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py new file mode 100644 index 0000000000000000000000000000000000000000..ca9a78cb0baafbf87323780ab7f3b20e1971317f --- /dev/null +++ b/opentech/apply/activity/options.py @@ -0,0 +1,24 @@ +from enum import Enum + + +class MESSAGES(Enum): + UPDATE_LEAD = 'Update Lead' + EDIT = 'Edit' + APPLICANT_EDIT = "Applicant Edit" + NEW_SUBMISSION = 'New Submission' + TRANSITION = 'Transition' + DETERMINATION_OUTCOME = 'Determination Outcome' + INVITED_TO_PROPOSAL = 'Invited To Proposal' + REVIEWERS_UPDATED = 'Reviewers Updated' + READY_FOR_REVIEW = 'Ready For Review' + NEW_REVIEW = 'New Review' + COMMENT = 'Comment' + PROPOSAL_SUBMITTED = 'Proposal Submitted' + OPENED_SEALED = 'Opened Sealed Submission' + + @classmethod + def choices(cls): + return [ + (choice.name, choice.value) + for choice in cls + ] diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html index dba0bc5b735d75d9f5ec1eb482a1ab0085636d5d..5f567ffe2f11e8ad7aba371ce7a75983009e8d4d 100644 --- a/opentech/apply/activity/templates/activity/include/listing_base.html +++ b/opentech/apply/activity/templates/activity/include/listing_base.html @@ -1,3 +1,4 @@ +{% load activity_tags %} <div class="feed__item feed__item--{{ activity.type }}"> <div class="feed__pre-content"> <p class="feed__label feed__label--{{ activity.type }}">{{ activity.type|capfirst }}</p> @@ -14,13 +15,23 @@ {% endif %} </div> <p class="feed__heading"> - <span>{{ activity.user }}</span> + <span>{{ activity|display_author:request.user }}</span> {% if submission_title %} updated <a href="{{ activity.submission.get_absolute_url }}">{{ activity.submission.title }}</a> {% endif %} - {{ activity.message }} + + {% if not submission_title and activity|user_can_see_related:request.user %} + {% with url=activity.related_object.get_absolute_url %} + {% if url %} + <a href="{{ url }}" class="feed__related-item"> + <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> + </a> + {% endif %} + {% endwith %} + {% endif %} </p> </div> </div> diff --git a/opentech/apply/activity/templatetags/__init__.py b/opentech/apply/activity/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..6f8c87ba50afb7cdca21112a8517701ebf75636f --- /dev/null +++ b/opentech/apply/activity/templatetags/activity_tags.py @@ -0,0 +1,27 @@ +from django import template + +from opentech.apply.determinations.models import Determination +from opentech.apply.review.models import Review + +register = template.Library() + + +@register.filter +def display_author(activity, user): + if user.is_applicant and isinstance(activity.related_object, Review): + return 'Reviewer' + return activity.user + + +@register.filter +def user_can_see_related(activity, user): + if not activity.related_object: + return False + + if user.is_apply_staff: + return True + + if isinstance(activity.related_object, Determination): + return True + + return False diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index 77b3ca5924aa1f91523f6d970eb6301711355019..54576e91c79a8a363f03cb2ca41e38f0d260a1dc 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -19,6 +19,7 @@ from ..messaging import ( ActivityAdapter, EmailAdapter, MessengerBackend, + neat_related, MESSAGES, SlackAdapter, ) @@ -46,18 +47,22 @@ class TestAdapter(AdapterBase): class AdapterMixin: adapter = None - def process_kwargs(self, **kwargs): + def process_kwargs(self, message_type, **kwargs): if 'user' not in kwargs: kwargs['user'] = UserFactory() if 'submission' not in kwargs: kwargs['submission'] = ApplicationSubmissionFactory() if 'request' not in kwargs: kwargs['request'] = make_request() + if message_type in neat_related: + kwargs['related'] = kwargs.get('related', 'a thing') + else: + kwargs['related'] = None return kwargs def adapter_process(self, message_type, **kwargs): - kwargs = self.process_kwargs(**kwargs) + kwargs = self.process_kwargs(message_type, **kwargs) self.adapter.process(message_type, event=EventFactory(submission=kwargs['submission']), **kwargs) @@ -139,6 +144,7 @@ class TestMessageBackend(TestCase): self.mocked_adapter = Mock(AdapterBase) self.backend = MessengerBackend self.kwargs = { + 'related': None, 'request': None, 'user': UserFactory(), 'submission': ApplicationSubmissionFactory(), @@ -185,7 +191,7 @@ class TestActivityAdapter(TestCase): user = UserFactory() submission = ApplicationSubmissionFactory() - self.adapter.send_message(message, user=user, submission=submission) + self.adapter.send_message(message, user=user, submission=submission, related=None) self.assertEqual(Activity.objects.count(), 1) activity = Activity.objects.first() @@ -318,14 +324,14 @@ class TestEmailAdapter(AdapterMixin, TestCase): def test_no_email_private_comment(self): comment = CommentFactory(internal=True) - self.adapter_process(MESSAGES.COMMENT, comment=comment, submission=comment.submission) + self.adapter_process(MESSAGES.COMMENT, related=comment, submission=comment.submission) self.assertEqual(len(mail.outbox), 0) def test_no_email_own_comment(self): application = ApplicationSubmissionFactory() comment = CommentFactory(user=application.user, submission=application) - self.adapter_process(MESSAGES.COMMENT, comment=comment, user=comment.user, submission=comment.submission) + self.adapter_process(MESSAGES.COMMENT, related=comment, user=comment.user, submission=comment.submission) self.assertEqual(len(mail.outbox), 0) def test_reviewers_email(self): diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index ad90ea369daa8c50cb1d9b69468ea598887f894c..07ba2d1dcb64df46393c08315b44aaf410260ad0 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -34,9 +34,13 @@ class ActivityContextMixin: extra = { 'actions': Activity.actions.filter(submission=self.object).select_related( 'user', + ).prefetch_related( + 'related_object', ).visible_to(self.request.user), 'comments': Activity.comments.filter(submission=self.object).select_related( 'user', + ).prefetch_related( + 'related_object', ).visible_to(self.request.user), } @@ -57,7 +61,7 @@ class CommentFormView(DelegatedViewMixin, CreateView): request=self.request, user=self.request.user, submission=self.object.submission, - comment=self.object, + related=self.object, ) return response diff --git a/opentech/apply/determinations/views.py b/opentech/apply/determinations/views.py index 2225a20aed84cf1d0b2ef5777802bddbd1959d78..7be2e9a41722decac551578287d134b870e41975 100644 --- a/opentech/apply/determinations/views.py +++ b/opentech/apply/determinations/views.py @@ -92,7 +92,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): request=self.request, user=self.object.author, submission=self.object.submission, - determination=self.object, + related=self.object, ) transition = transition_from_outcome(int(form.cleaned_data.get('outcome')), self.submission) @@ -102,6 +102,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): message=self.object.stripped_message, user=self.request.user, submission=self.submission, + related_object=self.object, ) self.submission.perform_transition(transition, self.request.user, request=self.request, notify=False) diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py index eeaf49583146c37d79a50cace03b326e7b1091ff..93092d92110b0c9edf469a631209fccf53882d32 100644 --- a/opentech/apply/funds/models/submissions.py +++ b/opentech/apply/funds/models/submissions.py @@ -440,8 +440,8 @@ class ApplicationSubmission( self.draft_revision = revision self.save() - return True - return False + return revision + return None def clean_submission(self): self.process_form_data() @@ -596,7 +596,7 @@ def log_status_update(sender, **kwargs): user=by, request=request, submission=instance, - old_phase=old_phase, + related=old_phase, ) if instance.status in review_statuses: @@ -635,3 +635,12 @@ class ApplicationRevision(AccessFormData, models.Model): 'to': self.submission.live_revision.id, 'from': self.id, }) + + def get_absolute_url(self): + # Compares against the previous revision + previous_revision = self.submission.revisions.filter(id__lt=self.id).first() + return reverse("funds:submissions:revisions:compare", kwargs={ + 'submission_pk': self.submission.id, + 'to': self.id, + 'from': previous_revision.id, + }) diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py index eb27dc0dd2223153aea20c107748975e8a337ad7..8e614ba85c6ab824ced211c40325c1360571aa4f 100644 --- a/opentech/apply/funds/views.py +++ b/opentech/apply/funds/views.py @@ -104,7 +104,7 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView): request=self.request, user=self.request.user, submission=form.instance, - old=old, + related=old.lead, ) return response @@ -280,13 +280,14 @@ class AdminSubmissionEditView(BaseSubmissionEditView): return self.form_invalid(form) if 'submit' in self.request.POST: - created = self.object.create_revision(by=self.request.user) - if created: + revision = self.object.create_revision(by=self.request.user) + if revision: messenger( MESSAGES.EDIT, request=self.request, user=self.request.user, submission=self.object, + related=revision, ) return HttpResponseRedirect(self.get_success_url()) @@ -315,7 +316,7 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView): messages.success(self.request, _('Submission saved successfully')) return self.form_invalid(form) - created = self.object.create_revision(by=self.request.user) + revision = self.object.create_revision(by=self.request.user) submitting_proposal = self.object.phase.name in STAGE_CHANGE_ACTIONS if submitting_proposal: @@ -325,12 +326,13 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView): user=self.request.user, submission=self.object, ) - elif created: + elif revision: messenger( MESSAGES.APPLICANT_EDIT, request=self.request, user=self.request.user, submission=self.object, + related=revision, ) action = set(self.request.POST.keys()) & set(self.transitions.keys()) @@ -340,7 +342,7 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView): transition.target, self.request.user, request=self.request, - notify=not (created or submitting_proposal), # Use the other notification + notify=not (revision or submitting_proposal), # Use the other notification ) return HttpResponseRedirect(self.get_success_url()) diff --git a/opentech/apply/review/models.py b/opentech/apply/review/models.py index 361a8d0c352070d509c80726df33cfd0badb48f9..75cfa7c1fad1fd8419eab33dbf4d45997db14aba 100644 --- a/opentech/apply/review/models.py +++ b/opentech/apply/review/models.py @@ -142,7 +142,7 @@ class Review(ReviewFormFieldsMixin, BaseStreamForm, AccessFormData, models.Model return '{:.1f}'.format(self.score) if self.score != NA else 'NA' def get_absolute_url(self): - return reverse('apply:reviews:review', args=(self.id,)) + return reverse('apply:submissions:reviews:review', args=(self.submission.pk, self.id,)) def __str__(self): return f'Review for {self.submission.title} by {self.author!s}' diff --git a/opentech/apply/review/views.py b/opentech/apply/review/views.py index 445efe2e4742f0c1e8c2442ea39b32c2e8aa34f5..70a3ad13ce6defe07672fec054c0c2630f393b40 100644 --- a/opentech/apply/review/views.py +++ b/opentech/apply/review/views.py @@ -89,7 +89,7 @@ class ReviewCreateOrUpdateView(BaseStreamForm, CreateOrUpdateView): request=self.request, user=self.object.author, submission=self.submission, - review=self.object, + related=self.object, ) return response diff --git a/opentech/static_src/src/sass/apply/components/_feed.scss b/opentech/static_src/src/sass/apply/components/_feed.scss index 71c84edeaeb1a669885c80498f7dc6238ca57def..8852c1926aa1ee515f7c210ff68fdecb2591e394 100644 --- a/opentech/static_src/src/sass/apply/components/_feed.scss +++ b/opentech/static_src/src/sass/apply/components/_feed.scss @@ -110,6 +110,16 @@ } } + &__related-item { + svg { + width: 10px; + height: 14px; + margin-left: 10px; + margin-top: 0.35em; + fill: $color--dark-blue; + } + } + &__heading { margin-bottom: 0;