diff --git a/addressfield/fields.py b/addressfield/fields.py index 7692b9eb466616fc1bf94e1ee1ee077f6f4b8f1e..f039b7181e04d5af874dafcb857074ac020eb492 100644 --- a/addressfield/fields.py +++ b/addressfield/fields.py @@ -14,6 +14,10 @@ with open(filepath, encoding='utf8') as address_data: VALIDATION_DATA = {country['iso']: country for country in countries} +ADDRESS_FIELDS_ORDER = [ + 'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country' +] + def flatten_data(data): flattened = dict() diff --git a/opentech/apply/activity/admin.py b/opentech/apply/activity/admin.py index eacc4bfbde9664469434699c67ef262ac8762e47..11d7238c6b4652b7912534b3786a58316ced1cec 100644 --- a/opentech/apply/activity/admin.py +++ b/opentech/apply/activity/admin.py @@ -12,9 +12,9 @@ class MessageInline(admin.TabularInline): class EventAdmin(admin.ModelAdmin): - list_display = ('type', 'by', 'when', 'submission') + list_display = ('type', 'by', 'when', 'source') list_filter = ('type', 'when') - readonly_fields = ('type', 'submission', 'when', 'by') + readonly_fields = ('type', 'source', 'when', 'by') inlines = (MessageInline,) def has_add_permission(self, request): diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 52a4d4da6bf0b039aeeb691f53ff51ae82794e04..8343c3694040ebf61dae516df709ac2c49b359cf 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -54,6 +54,8 @@ neat_related = { MESSAGES.REVIEW_OPINION: 'opinion', MESSAGES.DELETE_REVIEW: 'review', MESSAGES.EDIT_REVIEW: 'review', + MESSAGES.CREATED_PROJECT: 'submission', + MESSAGES.UPDATE_PROJECT_LEAD: 'old_lead', } @@ -108,38 +110,38 @@ class AdapterBase: def recipients(self, message_type, **kwargs): raise NotImplementedError() - def batch_recipients(self, message_type, submissions, **kwargs): + def batch_recipients(self, message_type, sources, **kwargs): # Default batch recipients is to send a message to each of the recipients that would # receive a message under normal conditions return [ { - 'recipients': self.recipients(message_type, submission=submission, **kwargs), - 'submissions': [submission] + 'recipients': self.recipients(message_type, source=source, **kwargs), + 'sources': [source] } - for submission in submissions + for source in sources ] - def process_batch(self, message_type, events, request, user, submissions, related=None, **kwargs): - events_by_submission = { - event.submission.id: event + def process_batch(self, message_type, events, request, user, sources, related=None, **kwargs): + events_by_source = { + event.source.id: event for event in events } - for recipient in self.batch_recipients(message_type, submissions, **kwargs): + for recipient in self.batch_recipients(message_type, sources, **kwargs): recipients = recipient['recipients'] - submissions = recipient['submissions'] - events = [events_by_submission[submission.id] for submission in submissions] - self.process_send(message_type, recipients, events, request, user, submissions=submissions, submission=None, related=related, **kwargs) + sources = recipient['sources'] + events = [events_by_source[source.id] for source in sources] + self.process_send(message_type, recipients, events, request, user, sources=sources, source=None, related=related, **kwargs) - def process(self, message_type, event, request, user, submission, related=None, **kwargs): - recipients = self.recipients(message_type, submission=submission, related=related, **kwargs) - self.process_send(message_type, recipients, [event], request, user, submission, related=related, **kwargs) + def process(self, message_type, event, request, user, source, related=None, **kwargs): + recipients = self.recipients(message_type, source=source, related=related, **kwargs) + self.process_send(message_type, recipients, [event], request, user, source, related=related, **kwargs) - def process_send(self, message_type, recipients, events, request, user, submission, submissions=list(), related=None, **kwargs): + def process_send(self, message_type, recipients, events, request, user, source, sources=list(), related=None, **kwargs): kwargs = { 'request': request, 'user': user, - 'submission': submission, - 'submissions': submissions, + 'source': source, + 'sources': sources, 'related': related, **kwargs, } @@ -197,10 +199,10 @@ class ActivityAdapter(AdapterBase): messages = { MESSAGES.TRANSITION: 'handle_transition', MESSAGES.BATCH_TRANSITION: 'handle_batch_transition', - MESSAGES.NEW_SUBMISSION: 'Submitted {submission.title} for {submission.page.title}', + MESSAGES.NEW_SUBMISSION: 'Submitted {source.title} for {source.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 {source.lead}', MESSAGES.BATCH_UPDATE_LEAD: 'Batch Lead changed to {new_lead}', MESSAGES.DETERMINATION_OUTCOME: 'Sent a determination. Outcome: {determination.clean_outcome}', MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determination', @@ -210,14 +212,19 @@ class ActivityAdapter(AdapterBase): MESSAGES.PARTNERS_UPDATED: 'partners_updated', MESSAGES.NEW_REVIEW: 'Submitted a review', MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed', - MESSAGES.SCREENING: 'Screening status from {old_status} to {submission.screening_status}', - MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}' + MESSAGES.SCREENING: 'Screening status from {old_status} to {source.screening_status}', + MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}', + MESSAGES.CREATED_PROJECT: '{user} has created Project', + MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead} by {user}', + MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval', + MESSAGES.APPROVE_PROJECT: '{user} has approved', + MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes to for acceptance: "{comment}"', } def recipients(self, message_type, **kwargs): return [None] - def extra_kwargs(self, message_type, submission, submissions, **kwargs): + def extra_kwargs(self, message_type, source, sources, **kwargs): if message_type in [ MESSAGES.OPENED_SEALED, MESSAGES.REVIEWERS_UPDATED, @@ -225,11 +232,14 @@ class ActivityAdapter(AdapterBase): MESSAGES.REVIEW_OPINION, MESSAGES.BATCH_REVIEWERS_UPDATED, MESSAGES.PARTNERS_UPDATED, + MESSAGES.APPROVE_PROJECT, + MESSAGES.REQUEST_PROJECT_CHANGE, + MESSAGES.SEND_FOR_APPROVAL, ]: return {'visibility': TEAM} - submission = submission or submissions[0] - if is_transition(message_type) and not submission.phase.permissions.can_view(submission.user): + source = source or sources[0] + if is_transition(message_type) and not source.phase.permissions.can_view(source.user): # User's shouldn't see status activity changes for stages that aren't visible to the them return {'visibility': TEAM} return {} @@ -255,15 +265,15 @@ class ActivityAdapter(AdapterBase): ]) return ' '.join(base) - def batch_determination(self, submissions, determinations, **kwargs): - submission = submissions[0] + def batch_determination(self, sources, determinations, **kwargs): + submission = sources[0] determination = determinations[submission.id] return self.messages[MESSAGES.DETERMINATION_OUTCOME].format( determination=determination, - submission=submission, ) - def handle_transition(self, old_phase, submission, **kwargs): + def handle_transition(self, old_phase, source, **kwargs): + submission = source base_message = 'Progressed from {old_display} to {new_display}' new_phase = submission.phase @@ -290,11 +300,12 @@ class ActivityAdapter(AdapterBase): return staff_message - def handle_batch_transition(self, transitions, submissions, **kwargs): - kwargs.pop('submission') + def handle_batch_transition(self, transitions, sources, **kwargs): + submissions = sources + kwargs.pop('source') for submission in submissions: old_phase = transitions[submission.id] - return self.handle_transition(old_phase=old_phase, submission=submission, **kwargs) + return self.handle_transition(old_phase=old_phase, source=submission, **kwargs) def partners_updated(self, added, removed, **kwargs): message = ['Partners updated.'] @@ -308,23 +319,24 @@ class ActivityAdapter(AdapterBase): return ' '.join(message) - def send_message(self, message, user, submission, submissions, **kwargs): + def send_message(self, message, user, source, sources, **kwargs): from .models import Activity visibility = kwargs.get('visibility', ALL) try: # If this was a batch action we want to pull out the submission - submission = submissions[0] + source = sources[0] except IndexError: pass related = kwargs['related'] if isinstance(related, dict): try: - related = related[submission.id] + related = related[source.id] except KeyError: pass + # TODO resolve how related objects work with submission/project has_correct_fields = all(hasattr(related, attr) for attr in ['author', 'submission', 'get_absolute_url']) if has_correct_fields and isinstance(related, models.Model): related_object = related @@ -333,7 +345,7 @@ class ActivityAdapter(AdapterBase): Activity.actions.create( user=user, - submission=submission, + source=source, timestamp=timezone.now(), message=message, visibility=visibility, @@ -345,29 +357,34 @@ class SlackAdapter(AdapterBase): adapter_type = "Slack" 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.NEW_SUBMISSION: 'A new submission has been submitted for {source.page.title}: <{link}|{source.title}>', + MESSAGES.UPDATE_LEAD: 'The lead of <{link}|{source.title}> has been updated from {old_lead} to {source.lead} by {user}', MESSAGES.BATCH_UPDATE_LEAD: 'handle_batch_lead', - MESSAGES.COMMENT: 'A new {comment.visibility} comment has been posted on <{link}|{submission.title}> by {user}', - MESSAGES.EDIT: '{user} has edited <{link}|{submission.title}>', - MESSAGES.APPLICANT_EDIT: '{user} has edited <{link}|{submission.title}>', + MESSAGES.COMMENT: 'A new {comment.visibility} comment has been posted on <{link}|{source.title}> by {user}', + MESSAGES.EDIT: '{user} has edited <{link}|{source.title}>', + MESSAGES.APPLICANT_EDIT: '{user} has edited <{link}|{source.title}>', MESSAGES.REVIEWERS_UPDATED: 'reviewers_updated', MESSAGES.BATCH_REVIEWERS_UPDATED: 'handle_batch_reviewers', - MESSAGES.PARTNERS_UPDATED: '{user} has updated the partners on <{link}|{submission.title}>', - MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{submission.title}>: {old_phase.display_name} → {submission.phase}', + MESSAGES.PARTNERS_UPDATED: '{user} has updated the partners on <{link}|{source.title}>', + MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{source.title}>: {old_phase.display_name} → {source.phase}', MESSAGES.BATCH_TRANSITION: 'handle_batch_transition', - MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{submission.title}> was sent by email. Outcome: {determination.clean_outcome}', + MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{source.title}> was sent by email. Outcome: {determination.clean_outcome}', MESSAGES.BATCH_DETERMINATION_OUTCOME: 'handle_batch_determination', - MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{submission.title}>', - MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{submission.title}> by {submission.user} has been invited to submit a proposal', - MESSAGES.NEW_REVIEW: '{user} has submitted a review for <{link}|{submission.title}>. Outcome: {review.outcome}, Score: {review.get_score_display}', + MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{source.title}>', + MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{source.title}> by {source.user} has been invited to submit a proposal', + MESSAGES.NEW_REVIEW: '{user} has submitted a review for <{link}|{source.title}>. Outcome: {review.outcome}, Score: {review.get_score_display}', MESSAGES.READY_FOR_REVIEW: 'notify_reviewers', - MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{submission.title}>', - MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}', + MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{source.title}>', + MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source.title}', MESSAGES.BATCH_READY_FOR_REVIEW: 'batch_notify_reviewers', - MESSAGES.DELETE_SUBMISSION: '{user} has deleted {submission.title}', - MESSAGES.DELETE_REVIEW: '{user} has deleted {review.author} review for <{link}|{submission.title}>.', - MESSAGES.EDIT_REVIEW: '{user} has edited {review.author} review for <{link}|{submission.title}>.', + MESSAGES.DELETE_SUBMISSION: '{user} has deleted {source.title}', + MESSAGES.DELETE_REVIEW: '{user} has deleted {review.author} review for <{link}|{source.title}>.', + MESSAGES.CREATED_PROJECT: '{user} has created a Project: <{link}|{source.title}>.', + MESSAGES.UPDATE_PROJECT_LEAD: 'The lead of project <{link}|{source.title}> has been updated from {old_lead} to {source.lead} by {user}', + MESSAGES.EDIT_REVIEW: '{user} has edited {review.author} review for <{link}|{source.title}>.', + MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on project <{link}|{source.title}>.', + MESSAGES.APPROVE_PROJECT: '{user} has approved project <{link}|{source.title}>.', + MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes for project acceptance on <{link}|{source.title}>.', } def __init__(self): @@ -375,47 +392,56 @@ class SlackAdapter(AdapterBase): self.destination = settings.SLACK_DESTINATION_URL self.target_room = settings.SLACK_DESTINATION_ROOM - def slack_links(self, links, submissions): + def slack_links(self, links, sources): return ', '.join( - f'<{links[submission.id]}|{submission.title}>' - for submission in submissions + f'<{links[source.id]}|{source.title}>' + for source in sources ) def extra_kwargs(self, message_type, **kwargs): - submission = kwargs['submission'] - submissions = kwargs['submissions'] + source = kwargs['source'] + sources = kwargs['sources'] request = kwargs['request'] - link = link_to(submission, request) + link = link_to(source, request) links = { - submission.id: link_to(submission, request) - for submission in submissions + source.id: link_to(source, request) + for source in sources } return { 'link': link, 'links': links, } - def recipients(self, message_type, submission, related, **kwargs): - recipients = [self.slack_id(submission.lead)] + def recipients(self, message_type, source, related, **kwargs): + if message_type == MESSAGES.SEND_FOR_APPROVAL: + return [ + self.slack_id(user) + for user in User.objects.approvers() + if self.slack_id(user) + ] + + recipients = [self.slack_id(source.lead)] # Notify second reviewer when first reviewer is done. if message_type == MESSAGES.NEW_REVIEW and related: + submission = source if submission.assigned.with_roles().count() == 2 and related.author.reviewer == submission.assigned.with_roles().first().reviewer: recipients.append(self.slack_id(submission.assigned.with_roles().last().reviewer)) return recipients - def batch_recipients(self, message_type, submissions, **kwargs): + def batch_recipients(self, message_type, sources, **kwargs): # We group the messages by lead - leads = User.objects.filter(id__in=submissions.values('lead')) + leads = User.objects.filter(id__in=sources.values('lead')) return [ { 'recipients': [self.slack_id(lead)], - 'submissions': submissions.filter(lead=lead), + 'sources': sources.filter(lead=lead), } for lead in leads ] - def reviewers_updated(self, submission, link, user, added=list(), removed=list(), **kwargs): + def reviewers_updated(self, source, link, user, added=list(), removed=list(), **kwargs): + submission = source message = [f'{user} has updated the reviewers on <{link}|{submission.title}>.'] if added: @@ -428,7 +454,8 @@ class SlackAdapter(AdapterBase): return ' '.join(message) - def handle_batch_lead(self, submissions, links, user, new_lead, **kwargs): + def handle_batch_lead(self, sources, links, user, new_lead, **kwargs): + submissions = sources submissions_text = self.slack_links(links, submissions) return ( '{user} has batch changed lead to {new_lead} on: {submissions_text}'.format( @@ -438,7 +465,8 @@ class SlackAdapter(AdapterBase): ) ) - def handle_batch_reviewers(self, submissions, links, user, added, **kwargs): + def handle_batch_reviewers(self, sources, links, user, added, **kwargs): + submissions = sources submissions_text = self.slack_links(links, submissions) reviewers_text = ' '.join([ f'{str(user)} as {role.name},' @@ -453,7 +481,8 @@ class SlackAdapter(AdapterBase): ) ) - def handle_batch_transition(self, user, links, submissions, transitions, **kwargs): + def handle_batch_transition(self, user, links, sources, transitions, **kwargs): + submissions = sources submissions_text = [ ': '.join([ self.slack_links(links, [submission]), @@ -469,7 +498,8 @@ class SlackAdapter(AdapterBase): ) ) - def handle_batch_determination(self, submissions, links, determinations, **kwargs): + def handle_batch_determination(self, sources, links, determinations, **kwargs): + submissions = sources submissions_links = ','.join([ self.slack_links(links, [submission]) for submission in submissions @@ -484,7 +514,8 @@ class SlackAdapter(AdapterBase): ) ) - def notify_reviewers(self, submission, link, **kwargs): + def notify_reviewers(self, source, link, **kwargs): + submission = source reviewers_to_notify = [] for reviewer in submission.reviewers.all(): if submission.phase.permissions.can_review(reviewer): @@ -502,23 +533,27 @@ class SlackAdapter(AdapterBase): ) ) - def batch_notify_reviewers(self, submissions, links, **kwargs): - kwargs.pop('submission') + def batch_notify_reviewers(self, sources, links, **kwargs): + kwargs.pop('source') kwargs.pop('link') return '. '.join( - self.notify_reviewers(submission, link=links[submission.id], **kwargs) - for submission in submissions + self.notify_reviewers(source, link=links[source.id], **kwargs) + for source in sources ) def slack_id(self, user): - if user.slack: - return f'<{user.slack}>' - return '' + if user is None: + return '' - def slack_channels(self, submission): + if not user.slack: + return '' + + return f'<{user.slack}>' + + def slack_channels(self, source): target_rooms = [self.target_room] try: - extra_rooms = submission.get_from_parent('slack_channel').split(',') + extra_rooms = source.get_from_parent('slack_channel').split(',') except AttributeError: # Not a submission object, no extra rooms. pass @@ -534,8 +569,8 @@ class SlackAdapter(AdapterBase): return target_rooms - def send_message(self, message, recipient, submission, **kwargs): - target_rooms = self.slack_channels(submission) + def send_message(self, message, recipient, source, **kwargs): + target_rooms = self.slack_channels(source) if not self.destination or not any(target_rooms): errors = list() @@ -573,21 +608,25 @@ class EmailAdapter(AdapterBase): MESSAGES.PARTNERS_UPDATED_PARTNER: 'partners_updated_partner', } - def get_subject(self, message_type, submission): - if submission: + def get_subject(self, message_type, source): + if source: if is_ready_for_review(message_type): - subject = 'Application ready to review: {submission.title}'.format(submission=submission) + subject = 'Application ready to review: {submission.title}'.format(submission=source) else: - subject = submission.page.specific.subject or 'Your application to {org_long_name}: {submission.title}'.format(org_long_name=settings.ORG_LONG_NAME, submission=submission) + try: + subject = source.page.specific.subject or 'Your application to {org_long_name}: {source.title}'.format(org_long_name=settings.ORG_LONG_NAME, source=source) + except AttributeError: + subject = 'Your {org_long_name} Project: {source.title}'.format(org_long_name=settings.ORG_LONG_NAME, source=source) return subject - def extra_kwargs(self, message_type, submission, submissions, **kwargs): + def extra_kwargs(self, message_type, source, sources, **kwargs): return { - 'subject': self.get_subject(message_type, submission), + 'subject': self.get_subject(message_type, source), } - def handle_transition(self, old_phase, submission, **kwargs): + def handle_transition(self, old_phase, source, **kwargs): from opentech.apply.funds.workflow import PHASES + submission = source # Retrive status index to see if we are going forward or backward. old_index = list(dict(PHASES).keys()).index(old_phase.name) target_index = list(dict(PHASES).keys()).index(submission.status) @@ -596,71 +635,73 @@ class EmailAdapter(AdapterBase): if is_forward: return self.render_message( 'messages/email/transition.html', - submission=submission, + source=submission, old_phase=old_phase, **kwargs ) - def handle_batch_transition(self, transitions, submissions, **kwargs): - kwargs.pop('submission') + def handle_batch_transition(self, transitions, sources, **kwargs): + submissions = sources + kwargs.pop('source') for submission in submissions: old_phase = transitions[submission.id] - return self.handle_transition(old_phase=old_phase, submission=submission, **kwargs) + return self.handle_transition(old_phase=old_phase, source=submission, **kwargs) - def batch_determination(self, determinations, submissions, **kwargs): - kwargs.pop('submission') + def batch_determination(self, determinations, sources, **kwargs): + submissions = sources + kwargs.pop('source') for submission in submissions: determination = determinations[submission.id] return self.render_message( 'messages/email/determination.html', - submission=submission, + source=submission, determination=determination, **kwargs ) def notify_comment(self, **kwargs): comment = kwargs['comment'] - submission = kwargs['submission'] - if not comment.priviledged and not comment.user == submission.user: + source = kwargs['source'] + if not comment.priviledged and not comment.user == source.user: return self.render_message('messages/email/comment.html', **kwargs) - def recipients(self, message_type, submission, **kwargs): + def recipients(self, message_type, source, **kwargs): if is_ready_for_review(message_type): - return self.reviewers(submission) + return self.reviewers(source) if is_transition(message_type): # Only notify the applicant if the new phase can be seen within the workflow - if not submission.phase.permissions.can_view(submission.user): + if not source.phase.permissions.can_view(source.user): return [] if message_type == MESSAGES.PARTNERS_UPDATED_PARTNER: partners = kwargs['added'] return [partner.email for partner in partners] - return [submission.user.email] + return [source.user.email] - def batch_recipients(self, message_type, submissions, **kwargs): + def batch_recipients(self, message_type, sources, **kwargs): if not is_ready_for_review(message_type): - return super().batch_recipients(message_type, submissions, **kwargs) + return super().batch_recipients(message_type, sources, **kwargs) reviewers_to_message = defaultdict(list) - for submission in submissions: - reviewers = self.reviewers(submission) + for source in sources: + reviewers = self.reviewers(source) for reviewer in reviewers: - reviewers_to_message[reviewer].append(submission) + reviewers_to_message[reviewer].append(source) return [ { 'recipients': [reviewer], - 'submissions': submissions, - } for reviewer, submissions in reviewers_to_message.items() + 'sources': sources, + } for reviewer, sources in reviewers_to_message.items() ] - def reviewers(self, submission): + def reviewers(self, source): return [ reviewer.email - for reviewer in submission.missing_reviewers.all() - if submission.phase.permissions.can_review(reviewer) and not reviewer.is_apply_staff + for reviewer in source.missing_reviewers.all() + if source.phase.permissions.can_review(reviewer) and not reviewer.is_apply_staff ] def partners_updated_applicant(self, added, removed, **kwargs): @@ -678,12 +719,12 @@ class EmailAdapter(AdapterBase): def render_message(self, template, **kwargs): return render_to_string(template, kwargs) - def send_message(self, message, submission, subject, recipient, logs, **kwargs): + def send_message(self, message, source, subject, recipient, logs, **kwargs): try: send_mail( subject, message, - submission.page.specific.from_address, + source.page.specific.from_address, [recipient], logs=logs ) @@ -699,9 +740,11 @@ class DjangoMessagesAdapter(AdapterBase): MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', MESSAGES.BATCH_TRANSITION: 'batch_transition', MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations', + MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document "{title}"', + MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document "{title}"', } - def batch_reviewers_updated(self, added, submissions, **kwargs): + def batch_reviewers_updated(self, added, sources, **kwargs): reviewers_text = ' '.join([ f'{str(user)} as {role.name},' for role, user in added @@ -712,10 +755,10 @@ class DjangoMessagesAdapter(AdapterBase): 'Batch reviewers added: ' + reviewers_text + ' to ' + - ', '.join(['"{}"'.format(submission.title) for submission in submissions]) + ', '.join(['"{}"'.format(source.title) for source in sources]) ) - def batch_transition(self, submissions, transitions, **kwargs): + def batch_transition(self, sources, transitions, **kwargs): base_message = 'Successfully updated:' transition = '{submission} [{old_display} → {new_display}].' transition_messages = [ @@ -723,12 +766,13 @@ class DjangoMessagesAdapter(AdapterBase): submission=submission.title, old_display=transitions[submission.id], new_display=submission.phase, - ) for submission in submissions + ) for submission in sources ] messages = [base_message, *transition_messages] return ' '.join(messages) - def batch_determinations(self, submissions, determinations, **kwargs): + def batch_determinations(self, sources, determinations, **kwargs): + submissions = sources outcome = determinations[submissions[0].id].clean_outcome base_message = f'Successfully determined as {outcome}: ' @@ -740,10 +784,10 @@ class DjangoMessagesAdapter(AdapterBase): def recipients(self, *args, **kwargs): return [None] - def batch_recipients(self, message_type, submissions, *args, **kwargs): + def batch_recipients(self, message_type, sources, *args, **kwargs): return [{ 'recipients': [None], - 'submissions': submissions, + 'sources': sources, }] def send_message(self, message, request, **kwargs): @@ -757,20 +801,20 @@ class MessengerBackend: def __call__(self, *args, related=None, **kwargs): return self.send(*args, related=related, **kwargs) - def send(self, message_type, request, user, related, submission=None, submissions=list(), **kwargs): + def send(self, message_type, request, user, related, source=None, sources=list(), **kwargs): from .models import Event - if submission: - event = Event.objects.create(type=message_type.name, by=user, submission=submission) + if source: + event = Event.objects.create(type=message_type.name, by=user, source=source) for adapter in self.adapters: - adapter.process(message_type, event, request=request, user=user, submission=submission, related=related, **kwargs) + adapter.process(message_type, event, request=request, user=user, source=source, related=related, **kwargs) - elif submissions: + elif sources: events = Event.objects.bulk_create( - Event(type=message_type.name, by=user, submission=submission) - for submission in submissions + Event(type=message_type.name, by=user, source=source) + for source in sources ) for adapter in self.adapters: - adapter.process_batch(message_type, events, request=request, user=user, submissions=submissions, related=related, **kwargs) + adapter.process_batch(message_type, events, request=request, user=user, sources=sources, related=related, **kwargs) adapters = [ diff --git a/opentech/apply/activity/migrations/0026_add_created_project_event.py b/opentech/apply/activity/migrations/0026_add_created_project_event.py new file mode 100644 index 0000000000000000000000000000000000000000..05053e4d20eb47adac7592943079fef55e4d11eb --- /dev/null +++ b/opentech/apply/activity/migrations/0026_add_created_project_event.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-07-29 10:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0025_add_batch_lead_event'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('EDIT_REVIEW', 'Edit Review')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0027_add_update_project_lead.py b/opentech/apply/activity/migrations/0027_add_update_project_lead.py new file mode 100644 index 0000000000000000000000000000000000000000..e10f100e188fadfe2c0e0a3cd70c79f1574a2427 --- /dev/null +++ b/opentech/apply/activity/migrations/0027_add_update_project_lead.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-01 09:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0026_add_created_project_event'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0028_add_new_generic_relation.py b/opentech/apply/activity/migrations/0028_add_new_generic_relation.py new file mode 100644 index 0000000000000000000000000000000000000000..545a19f6e63d9c25da3d839fffa4021b0d200f4f --- /dev/null +++ b/opentech/apply/activity/migrations/0028_add_new_generic_relation.py @@ -0,0 +1,48 @@ +# Generated by Django 2.0.13 on 2019-07-10 17:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('activity', '0027_add_update_project_lead'), + ] + + operations = [ + # Updates to the existing GenericForeignKey to related objects + migrations.RenameField( + model_name='activity', + old_name='object_id', + new_name='related_object_id', + ), + migrations.RenameField( + model_name='activity', + old_name='content_type', + new_name='related_content_type', + ), + migrations.AlterField( + model_name='activity', + name='related_content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity_related', to='contenttypes.ContentType'), + ), + # Add the new generic foreign key + migrations.AddField( + model_name='activity', + name='source_content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity_source', to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='activity', + name='source_object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + # Make the submission field nullable + migrations.AlterField( + model_name='activity', + name='submission', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='funds.ApplicationSubmission', related_name='activities', null=True) + ), + ] diff --git a/opentech/apply/activity/migrations/0029_migrate_old_submission_relation.py b/opentech/apply/activity/migrations/0029_migrate_old_submission_relation.py new file mode 100644 index 0000000000000000000000000000000000000000..1c487b0f893f8f2f4cdc98e8592bea273cb2890d --- /dev/null +++ b/opentech/apply/activity/migrations/0029_migrate_old_submission_relation.py @@ -0,0 +1,33 @@ +# Generated by Django 2.0.13 on 2019-07-10 17:33 + +from django.db import migrations +from django.db.models import F + + +def submission_to_source(apps, schema_editor): + Activity = apps.get_model('activity', 'Activity') + if Activity.objects.exists(): + ContentType = apps.get_model('contenttypes', 'ContentType') + content_type = ContentType.objects.get(model='applicationsubmission', app_label='funds') + Activity.objects.update( + source_object_id=F('submission_id'), + source_content_type=content_type, + ) + + +def source_to_submission(apps, schema_editor): + Activity = apps.get_model('activity', 'Activity') + Activity.objects.update(submission_id=F('source_object_id')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0028_add_new_generic_relation'), + ('funds', '0065_applicationsubmission_meta_categories'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.RunPython(submission_to_source, source_to_submission) + ] diff --git a/opentech/apply/activity/migrations/0030_remove_old_relation.py b/opentech/apply/activity/migrations/0030_remove_old_relation.py new file mode 100644 index 0000000000000000000000000000000000000000..b5242315763380709c4e22a09c04c7fcbe7ed47b --- /dev/null +++ b/opentech/apply/activity/migrations/0030_remove_old_relation.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.13 on 2019-07-10 17:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0029_migrate_old_submission_relation'), + ] + + operations = [ + migrations.RemoveField( + model_name='activity', + name='submission', + ), + ] diff --git a/opentech/apply/activity/migrations/0031_add_generic_fk_to_event.py b/opentech/apply/activity/migrations/0031_add_generic_fk_to_event.py new file mode 100644 index 0000000000000000000000000000000000000000..f67b8a245e10d3dc927a2def3ca9ab80182200d9 --- /dev/null +++ b/opentech/apply/activity/migrations/0031_add_generic_fk_to_event.py @@ -0,0 +1,30 @@ +# Generated by Django 2.0.13 on 2019-07-10 22:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('activity', '0030_remove_old_relation'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='submission', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='funds.ApplicationSubmission'), + ), + migrations.AddField( + model_name='event', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AddField( + model_name='event', + name='object_id', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/opentech/apply/activity/migrations/0032_migrate_submission_to_generic_event.py b/opentech/apply/activity/migrations/0032_migrate_submission_to_generic_event.py new file mode 100644 index 0000000000000000000000000000000000000000..137d80d9c0c49a3955ad6bebc1242c2dc2d6c8e3 --- /dev/null +++ b/opentech/apply/activity/migrations/0032_migrate_submission_to_generic_event.py @@ -0,0 +1,33 @@ +# Generated by Django 2.0.13 on 2019-07-10 22:36 + +from django.db import migrations +from django.db.models import F + + +def submission_to_source(apps, schema_editor): + Event = apps.get_model('activity', 'Event') + if Event.objects.exists(): + ContentType = apps.get_model('contenttypes', 'ContentType') + content_type = ContentType.objects.get(model='applicationsubmission', app_label='funds') + Event.objects.update( + object_id=F('submission_id'), + content_type=content_type, + ) + + +def source_to_submission(apps, schema_editor): + Event = apps.get_model('activity', 'Event') + Event.objects.update(submission_id=F('object_id')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0031_add_generic_fk_to_event'), + ('funds', '0065_applicationsubmission_meta_categories'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.RunPython(submission_to_source, source_to_submission) + ] diff --git a/opentech/apply/activity/migrations/0033_remove_old_submission_fk_event.py b/opentech/apply/activity/migrations/0033_remove_old_submission_fk_event.py new file mode 100644 index 0000000000000000000000000000000000000000..6e76833da651d60e66a2f5d0a57061c9955c58c2 --- /dev/null +++ b/opentech/apply/activity/migrations/0033_remove_old_submission_fk_event.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.13 on 2019-07-10 22:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0032_migrate_submission_to_generic_event'), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='submission', + ), + ] diff --git a/opentech/apply/activity/migrations/0034_add_send_for_approval.py b/opentech/apply/activity/migrations/0034_add_send_for_approval.py new file mode 100644 index 0000000000000000000000000000000000000000..4dcb14b42ac373f6837dca31c7a84ec74b1ef01d --- /dev/null +++ b/opentech/apply/activity/migrations/0034_add_send_for_approval.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-05 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0033_remove_old_submission_fk_event'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0035_add_approve_project.py b/opentech/apply/activity/migrations/0035_add_approve_project.py new file mode 100644 index 0000000000000000000000000000000000000000..c1ebdd9e7be7c7f4a635396b69c379c35ee1e891 --- /dev/null +++ b/opentech/apply/activity/migrations/0035_add_approve_project.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-07 13:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0034_add_send_for_approval'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0036_add_reject_project.py b/opentech/apply/activity/migrations/0036_add_reject_project.py new file mode 100644 index 0000000000000000000000000000000000000000..640d50dd02f33fe548e24d29d49056766fb341fe --- /dev/null +++ b/opentech/apply/activity/migrations/0036_add_reject_project.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-07 13:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0035_add_approve_project'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0037_add_upload_document.py b/opentech/apply/activity/migrations/0037_add_upload_document.py new file mode 100644 index 0000000000000000000000000000000000000000..6220a12883f325980e280180b82d442270229fdb --- /dev/null +++ b/opentech/apply/activity/migrations/0037_add_upload_document.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-08 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0036_add_reject_project'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REJECT_PROJECT', 'Project was Rejected'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0038_auto_20190808_1142.py b/opentech/apply/activity/migrations/0038_auto_20190808_1142.py new file mode 100644 index 0000000000000000000000000000000000000000..47109db845f978f04c7889ca37c1e290ea0f4c15 --- /dev/null +++ b/opentech/apply/activity/migrations/0038_auto_20190808_1142.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-08 10:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0037_add_upload_document'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0039_add_remove_document.py b/opentech/apply/activity/migrations/0039_add_remove_document.py new file mode 100644 index 0000000000000000000000000000000000000000..c3014c711c3e30a5e0cdb3b7c8cc87085c462d49 --- /dev/null +++ b/opentech/apply/activity/migrations/0039_add_remove_document.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-08 14:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0038_auto_20190808_1142'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0040_merge_activity_update_and_generic_relations.py b/opentech/apply/activity/migrations/0040_merge_activity_update_and_generic_relations.py new file mode 100644 index 0000000000000000000000000000000000000000..e9ba43047688e6c900a77cae589184bfba6f3a22 --- /dev/null +++ b/opentech/apply/activity/migrations/0040_merge_activity_update_and_generic_relations.py @@ -0,0 +1,14 @@ +# Generated by Django 2.0.13 on 2019-08-13 18:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0028_migrate_messages_with_visibility'), + ('activity', '0039_add_remove_document'), + ] + + operations = [ + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index f6dd90837e2f0e5308df19b870ced4d1b0d7b902..44cd95989953279d6fde646bd0c7f3b323e2b85c 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -88,7 +88,11 @@ class Activity(models.Model): timestamp = models.DateTimeField() type = models.CharField(choices=ACTIVITY_TYPES.items(), max_length=30) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) - submission = models.ForeignKey('funds.ApplicationSubmission', related_name='activities', on_delete=models.CASCADE) + + source_content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE, related_name='activity_source') + source_object_id = models.PositiveIntegerField(blank=True, null=True) + source = GenericForeignKey('source_content_type', 'source_object_id') + message = models.TextField() visibility = models.CharField(choices=list(VISIBILITY.items()), default=APPLICANT, max_length=30) @@ -98,9 +102,9 @@ class Activity(models.Model): previous = models.ForeignKey("self", on_delete=models.CASCADE, null=True) # 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') + related_content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE, related_name='activity_related') + related_object_id = models.PositiveIntegerField(blank=True, null=True) + related_object = GenericForeignKey('related_content_type', 'related_object_id') objects = models.Manager.from_queryset(ActivityQuerySet)() comments = CommentManger.from_queryset(CommentQueryset)() @@ -145,7 +149,9 @@ class Event(models.Model): when = models.DateTimeField(auto_now_add=True) type = models.CharField(choices=MESSAGES.choices(), max_length=50) by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) - submission = models.ForeignKey('funds.ApplicationSubmission', related_name='+', on_delete=models.CASCADE) + content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(blank=True, null=True) + source = GenericForeignKey('content_type', 'object_id') def __str__(self): return ' '.join([self.get_type_display(), 'by:', str(self.by), 'on:', self.submission.title]) diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 1fd7988a748fff6ca610a28d3f8c6210913204f5..1dacb5fe6c8b5480174f6815d95aba5dbb01e46e 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -26,7 +26,14 @@ class MESSAGES(Enum): REVIEW_OPINION = 'Review Opinion' DELETE_SUBMISSION = 'Delete Submission' DELETE_REVIEW = 'Delete Review' + CREATED_PROJECT = 'Created Project' + UPDATE_PROJECT_LEAD = 'Update Project Lead' EDIT_REVIEW = 'Edit Review' + SEND_FOR_APPROVAL = 'Send for Approval' + APPROVE_PROJECT = 'Project was Approved' + REQUEST_PROJECT_CHANGE = 'Project change requested' + UPLOAD_DOCUMENT = 'Document was Uploaded to Project' + REMOVE_DOCUMENT = 'Document was Removed from Project' @classmethod def choices(cls): diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html index 95352d4d922dce15b32b2ffbb69523a802752db1..bdc55edf7ae7fa60bed5332b66916ae77fc3995e 100644 --- a/opentech/apply/activity/templates/activity/include/listing_base.html +++ b/opentech/apply/activity/templates/activity/include/listing_base.html @@ -32,7 +32,7 @@ <p class="feed__heading"> {% if submission_title %} - updated <a href="{{ activity.submission.get_absolute_url }}">{{ activity.submission.title }}</a> + updated <a href="{{ activity.source.get_absolute_url }}">{{ activity.source.title }}</a> {% endif %} {% if editable %} diff --git a/opentech/apply/activity/templates/messages/email/applicant_base.html b/opentech/apply/activity/templates/messages/email/applicant_base.html index 714bb3f013be3ce954b5aa7ef6cfaacfbc66b9e9..6ddf443bc8ea35c2364102ec4e189d76640494ce 100644 --- a/opentech/apply/activity/templates/messages/email/applicant_base.html +++ b/opentech/apply/activity/templates/messages/email/applicant_base.html @@ -1,8 +1,8 @@ {% extends "messages/email/base.html" %} -{% block salutation %}Dear {{ submission.user.get_full_name|default:"applicant" }},{% endblock %} +{% block salutation %}Dear {{ source.user.get_full_name|default:"applicant" }},{% endblock %} -{% block more_info %}You can access your application here: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }} -If you have any questions, please submit them here: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }}#communications +{% block more_info %}You can find more information here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +If you have any questions, please submit them here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}#communications If you have any issues accessing the submission system or other general inquiries, please email us at {{ ORG_EMAIL }} {% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html index 25d2aa7a6ab25a1c1c7e99841e5af8449ffde663..19ae54272ca3f2d5f68508bcd02822e953907028 100644 --- a/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html +++ b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html @@ -3,7 +3,7 @@ {% block content %} New proposals have been added to your review list. -{% for submission in submissions %} +{% for submission in sources %} Title: {{ submission.title }} Link: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }} diff --git a/opentech/apply/activity/templates/messages/email/comment.html b/opentech/apply/activity/templates/messages/email/comment.html index c092483e37e1ed63db7ece92aa2fd89938793761..e89ca785f5cd7252b0251d08d72a4bb4c493aedd 100644 --- a/opentech/apply/activity/templates/messages/email/comment.html +++ b/opentech/apply/activity/templates/messages/email/comment.html @@ -1,5 +1,5 @@ {% extends "messages/email/applicant_base.html" %} -{% block content %}There has been a new comment on your application: {{ submission.title }} +{% block content %}There has been a new comment on "{{ source.title }}" {{ comment.user }}: {{ comment.message }}{% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/ready_to_review.html b/opentech/apply/activity/templates/messages/email/ready_to_review.html index 1e9b3e67ca7bd63f593b20eb58edc065927f1728..98dabcef3f002a23eb79ce874ca2ccb236778fc4 100644 --- a/opentech/apply/activity/templates/messages/email/ready_to_review.html +++ b/opentech/apply/activity/templates/messages/email/ready_to_review.html @@ -4,6 +4,6 @@ {% block content %} A new proposal has been added to your review list. -Title: {{ submission.title }} -Link: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }} +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} {% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/transition.html b/opentech/apply/activity/templates/messages/email/transition.html index 522c08cf89bd1af54aab399d6313418cf887ea0c..4ac07ed0f1fca420ab93fd4fd95d47cba9d7d646 100644 --- a/opentech/apply/activity/templates/messages/email/transition.html +++ b/opentech/apply/activity/templates/messages/email/transition.html @@ -1,3 +1,3 @@ {% extends "messages/email/applicant_base.html" %} -{% block content %}Your application has been progressed from {{ old_phase.public_name }} to {{ submission.phase.public_name }}.{% endblock %} +{% block content %}Your application has been progressed from {{ old_phase.public_name }} to {{ source.phase.public_name }}.{% endblock %} diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py index 412c64aec2ad63ef11b7123c5cb12d0886184208..9a1aa5e4e087067dbcccd49b74b4042f78827bc0 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.submission.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/tests/factories.py b/opentech/apply/activity/tests/factories.py index 9c379c3c6c106c0a27a0dc1c8cc6e67fe497c51d..51a1b3fb57678e39aa849ad87582fe2b20977a86 100644 --- a/opentech/apply/activity/tests/factories.py +++ b/opentech/apply/activity/tests/factories.py @@ -16,7 +16,7 @@ class CommentFactory(factory.DjangoModelFactory): internal = factory.Trait(visibility=TEAM) reviewers = factory.Trait(visibility=REVIEWER) - submission = factory.SubFactory(ApplicationSubmissionFactory) + source = factory.SubFactory(ApplicationSubmissionFactory) user = factory.SubFactory(UserFactory) message = factory.Faker('sentence') timestamp = factory.LazyFunction(timezone.now) @@ -32,7 +32,7 @@ class EventFactory(factory.DjangoModelFactory): type = factory.Iterator([choice[0] for choice in MESSAGES.choices()]) by = factory.SubFactory(UserFactory) - submission = factory.SubFactory(ApplicationSubmissionFactory) + source = factory.SubFactory(ApplicationSubmissionFactory) class MessageFactory(factory.DjangoModelFactory): diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index 447c1065275817c952f4a01a4be6f496e89a6efa..6b6262de277e6f06d1b85181568393affaabe9d9 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -17,6 +17,7 @@ from opentech.apply.funds.tests.factories import ( ) from opentech.apply.review.tests.factories import ReviewFactory from opentech.apply.users.tests.factories import ReviewerFactory, UserFactory +from opentech.apply.projects.tests.factories import ProjectFactory from ..models import Activity, Event, Message, TEAM, ALL from ..messaging import ( @@ -52,12 +53,13 @@ class TestAdapter(AdapterBase): @override_settings(ROOT_URLCONF='opentech.apply.urls') class AdapterMixin(TestCase): adapter = None + source_factory = None def process_kwargs(self, message_type, **kwargs): if 'user' not in kwargs: kwargs['user'] = UserFactory() - if 'submission' not in kwargs: - kwargs['submission'] = ApplicationSubmissionFactory() + if 'source' not in kwargs: + kwargs['source'] = self.source_factory() if 'request' not in kwargs: kwargs['request'] = make_request() if message_type in neat_related: @@ -67,13 +69,18 @@ class AdapterMixin(TestCase): return kwargs - def adapter_process(self, message_type, **kwargs): + def adapter_process(self, message_type, adapter=None, **kwargs): + if not adapter: + adapter = self.adapter kwargs = self.process_kwargs(message_type, **kwargs) - self.adapter.process(message_type, event=EventFactory(submission=kwargs['submission']), **kwargs) + event = EventFactory(source=kwargs['source']) + adapter.process(message_type, event=event, **kwargs) @override_settings(SEND_MESSAGES=True) class TestBaseAdapter(AdapterMixin, TestCase): + source_factory = ApplicationSubmissionFactory + def setUp(self): patched_class = patch.object(TestAdapter, 'send_message') self.mock_adapter = patched_class.start() @@ -145,7 +152,9 @@ class TestBaseAdapter(AdapterMixin, TestCase): self.assertTrue(self.adapter.adapter_type in messages[0].message) -class TestMessageBackend(TestCase): +class TestMessageBackendApplication(TestCase): + source_factory = ApplicationSubmissionFactory + def setUp(self): self.mocked_adapter = Mock(AdapterBase) self.backend = MessengerBackend @@ -153,7 +162,7 @@ class TestMessageBackend(TestCase): 'related': None, 'request': None, 'user': UserFactory(), - 'submission': ApplicationSubmissionFactory(), + 'source': self.source_factory(), } def test_message_sent_to_adapter(self): @@ -187,6 +196,10 @@ class TestMessageBackend(TestCase): self.assertEqual(Event.objects.first().by, user) +class TestMessageBackendProject(TestMessageBackendApplication): + source_factory = ProjectFactory + + @override_settings(SEND_MESSAGES=True) class TestActivityAdapter(TestCase): def setUp(self): @@ -197,13 +210,13 @@ class TestActivityAdapter(TestCase): user = UserFactory() submission = ApplicationSubmissionFactory() - self.adapter.send_message(message, user=user, submission=submission, submissions=[], related=None) + self.adapter.send_message(message, user=user, source=submission, sources=[], related=None) self.assertEqual(Activity.objects.count(), 1) activity = Activity.objects.first() self.assertEqual(activity.user, user) self.assertEqual(activity.message, message) - self.assertEqual(activity.submission, submission) + self.assertEqual(activity.source, submission) def test_reviewers_message_no_removed(self): assigned_reviewer = AssignedReviewersFactory() @@ -247,13 +260,13 @@ class TestActivityAdapter(TestCase): def test_internal_transition_kwarg_for_invisible_transition(self): submission = ApplicationSubmissionFactory(status='post_review_discussion') - kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None) + kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, source=submission, sources=None) self.assertEqual(kwargs['visibility'], TEAM) def test_public_transition_kwargs(self): submission = ApplicationSubmissionFactory() - kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None) + kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, source=submission, sources=None) self.assertNotIn('visibility', kwargs) @@ -293,7 +306,7 @@ class TestActivityAdapter(TestCase): def test_lead_not_saved_on_activity(self): submission = ApplicationSubmissionFactory() user = UserFactory() - self.adapter.send_message('a message', user=user, submission=submission, submissions=[], related=user) + self.adapter.send_message('a message', user=user, source=submission, sources=[], related=user) activity = Activity.objects.first() self.assertEqual(activity.related_object, None) @@ -302,8 +315,8 @@ class TestActivityAdapter(TestCase): self.adapter.send_message( 'a message', user=review.author.reviewer, - submission=review.submission, - submissions=[], + source=review.submission, + sources=[], related=review, ) activity = Activity.objects.first() @@ -311,6 +324,8 @@ class TestActivityAdapter(TestCase): class TestSlackAdapter(AdapterMixin, TestCase): + source_factory = ApplicationSubmissionFactory + target_url = 'https://my-slack-backend.com/incoming/my-very-secret-key' target_room = '#<ROOM ID>' @@ -322,7 +337,7 @@ class TestSlackAdapter(AdapterMixin, TestCase): def test_cant_send_with_no_room(self): adapter = SlackAdapter() submission = ApplicationSubmissionFactory() - adapter.send_message('my message', '', submission) + adapter.send_message('my message', '', source=submission) self.assertEqual(len(responses.calls), 0) @override_settings( @@ -333,7 +348,7 @@ class TestSlackAdapter(AdapterMixin, TestCase): def test_cant_send_with_no_url(self): adapter = SlackAdapter() submission = ApplicationSubmissionFactory() - adapter.send_message('my message', '', submission) + adapter.send_message('my message', '', source=submission) self.assertEqual(len(responses.calls), 0) @override_settings( @@ -346,7 +361,7 @@ class TestSlackAdapter(AdapterMixin, TestCase): submission = ApplicationSubmissionFactory() adapter = SlackAdapter() message = 'my message' - adapter.send_message(message, '', submission) + adapter.send_message(message, '', source=submission) self.assertEqual(len(responses.calls), 1) self.assertDictEqual( json.loads(responses.calls[0].request.body), @@ -366,7 +381,7 @@ class TestSlackAdapter(AdapterMixin, TestCase): submission = ApplicationSubmissionFactory(page__slack_channel='dummy') adapter = SlackAdapter() message = 'my message' - adapter.send_message(message, '', submission) + adapter.send_message(message, '', source=submission) self.assertEqual(len(responses.calls), 1) self.assertDictEqual( json.loads(responses.calls[0].request.body), @@ -380,14 +395,14 @@ class TestSlackAdapter(AdapterMixin, TestCase): def test_gets_lead_if_slack_set(self): adapter = SlackAdapter() submission = ApplicationSubmissionFactory() - recipients = adapter.recipients(MESSAGES.COMMENT, submission, related=None) + recipients = adapter.recipients(MESSAGES.COMMENT, source=submission, related=None) self.assertTrue(submission.lead.slack in recipients[0]) @responses.activate def test_gets_blank_if_slack_not_set(self): adapter = SlackAdapter() submission = ApplicationSubmissionFactory(lead__slack='') - recipients = adapter.recipients(MESSAGES.COMMENT, submission, related=None) + recipients = adapter.recipients(MESSAGES.COMMENT, source=submission, related=None) self.assertTrue(submission.lead.slack in recipients[0]) @override_settings( @@ -423,11 +438,12 @@ class TestSlackAdapter(AdapterMixin, TestCase): @override_settings(SEND_MESSAGES=True) class TestEmailAdapter(AdapterMixin, TestCase): + source_factory = ApplicationSubmissionFactory adapter = EmailAdapter() def test_email_new_submission(self): submission = ApplicationSubmissionFactory() - self.adapter_process(MESSAGES.NEW_SUBMISSION, submission=submission) + self.adapter_process(MESSAGES.NEW_SUBMISSION, source=submission) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, [submission.user.email]) @@ -435,20 +451,20 @@ class TestEmailAdapter(AdapterMixin, TestCase): def test_no_email_private_comment(self): comment = CommentFactory(internal=True) - self.adapter_process(MESSAGES.COMMENT, related=comment, submission=comment.submission) + self.adapter_process(MESSAGES.COMMENT, related=comment, source=comment.source) self.assertEqual(len(mail.outbox), 0) def test_no_email_own_comment(self): application = ApplicationSubmissionFactory() - comment = CommentFactory(user=application.user, submission=application) + comment = CommentFactory(user=application.user, source=application) - self.adapter_process(MESSAGES.COMMENT, related=comment, user=comment.user, submission=comment.submission) + self.adapter_process(MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source) self.assertEqual(len(mail.outbox), 0) def test_reviewers_email(self): reviewers = ReviewerFactory.create_batch(4) submission = ApplicationSubmissionFactory(status='external_review', reviewers=reviewers, workflow_stages=2) - self.adapter_process(MESSAGES.READY_FOR_REVIEW, submission=submission) + self.adapter_process(MESSAGES.READY_FOR_REVIEW, source=submission) self.assertEqual(len(mail.outbox), 4) self.assertTrue(mail.outbox[0].subject, 'ready to review') @@ -492,7 +508,7 @@ class TestAnyMailBehaviour(AdapterMixin, TestCase): def test_email_new_submission(self): submission = ApplicationSubmissionFactory() - self.adapter_process(MESSAGES.NEW_SUBMISSION, submission=submission) + self.adapter_process(MESSAGES.NEW_SUBMISSION, source=submission) self.assertEqual(len(mail.outbox), 1) self.assertEqual(mail.outbox[0].to, [submission.user.email]) @@ -536,3 +552,93 @@ class TestAnyMailBehaviour(AdapterMixin, TestCase): message.refresh_from_db() self.assertTrue('rejected' in message.status) self.assertTrue('spam' in message.status) + + +class TestAdaptersForProject(AdapterMixin, TestCase): + slack = SlackAdapter + activity = ActivityAdapter + source_factory = ProjectFactory + # Slack + target_url = 'https://my-slack-backend.com/incoming/my-very-secret-key' + target_room = '#<ROOM ID>' + + def test_activity_lead_change(self): + old_lead = UserFactory() + project = self.source_factory() + self.adapter_process( + MESSAGES.UPDATE_PROJECT_LEAD, + adapter=self.activity(), + source=project, + related=old_lead, + ) + self.assertEqual(Activity.objects.count(), 1) + activity = Activity.objects.first() + self.assertIn(str(old_lead), activity.message) + self.assertIn(str(project.lead), activity.message) + + def test_activity_lead_change_from_none(self): + project = self.source_factory() + self.adapter_process( + MESSAGES.UPDATE_PROJECT_LEAD, + adapter=self.activity(), + source=project, + related='Unassigned', + ) + self.assertEqual(Activity.objects.count(), 1) + activity = Activity.objects.first() + self.assertIn(str('Unassigned'), activity.message) + self.assertIn(str(project.lead), activity.message) + + def test_activity_created(self): + project = self.source_factory() + self.adapter_process( + MESSAGES.CREATED_PROJECT, + adapter=self.activity(), + source=project, + related=project.submission, + ) + self.assertEqual(Activity.objects.count(), 1) + activity = Activity.objects.first() + self.assertEqual(None, activity.related_object) + + @override_settings( + SLACK_DESTINATION_URL=target_url, + SLACK_DESTINATION_ROOM=target_room, + ) + @responses.activate + def test_slack_created(self): + responses.add(responses.POST, self.target_url, status=200, body='OK') + project = self.source_factory() + user = UserFactory() + self.adapter_process( + MESSAGES.CREATED_PROJECT, + adapter=self.slack(), + user=user, + source=project, + related=project.submission, + ) + self.assertEqual(len(responses.calls), 1) + data = json.loads(responses.calls[0].request.body) + self.assertIn(str(user), data['message']) + self.assertIn(str(project), data['message']) + + @override_settings( + SLACK_DESTINATION_URL=target_url, + SLACK_DESTINATION_ROOM=target_room, + ) + @responses.activate + def test_slack_lead_change(self): + responses.add(responses.POST, self.target_url, status=200, body='OK') + project = self.source_factory() + user = UserFactory() + self.adapter_process( + MESSAGES.UPDATE_PROJECT_LEAD, + adapter=self.slack(), + user=user, + source=project, + related=project.submission, + ) + self.assertEqual(len(responses.calls), 1) + data = json.loads(responses.calls[0].request.body) + self.assertIn(str(user), data['message']) + self.assertIn(str(project), data['message']) diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index 25b216107e5528f0eaa36aaaad3f95c950b97b93..c0edaf6b7abe782220d0d241b14e1e2eeddfb64d 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -14,17 +14,20 @@ ACTIVITY_LIMIT = 50 class AllActivityContextMixin: def get_context_data(self, **kwargs): extra = { - 'actions': Activity.actions.filter(submission__in=self.object_list).select_related( - 'submission', + 'actions': Activity.actions.select_related( 'user', + ).prefetch_related( + 'source', )[:ACTIVITY_LIMIT], - 'comments': Activity.comments.filter(submission__in=self.object_list).select_related( - 'submission', + 'comments': Activity.comments.select_related( 'user', + ).prefetch_related( + 'source', )[:ACTIVITY_LIMIT], - 'all_activity': Activity.objects.filter(submission__in=self.object_list).select_related( - 'submission', + 'all_activity': Activity.objects.select_related( 'user', + ).prefetch_related( + 'source', )[:ACTIVITY_LIMIT], } return super().get_context_data(**extra, **kwargs) @@ -32,15 +35,17 @@ class AllActivityContextMixin: class ActivityContextMixin: def get_context_data(self, **kwargs): + related_query = self.model.activities.rel.related_query_name + query = {related_query: self.object} extra = { # Do not prefetch on the related_object__author as the models # are not homogeneous and this will fail - 'actions': Activity.actions.filter(submission=self.object).select_related( + 'actions': Activity.actions.filter(**query).select_related( 'user', ).prefetch_related( 'related_object__submission', ).visible_to(self.request.user), - 'comments': Activity.comments.filter(submission=self.object).select_related( + 'comments': Activity.comments.filter(**query).select_related( 'user', ).prefetch_related( 'related_object__submission', @@ -54,8 +59,9 @@ class CommentFormView(DelegatedViewMixin, CreateView): context_name = 'comment_form' def form_valid(self, form): + source = self.kwargs['object'] form.instance.user = self.request.user - form.instance.submission = self.kwargs['submission'] + form.instance.source = source form.instance.type = COMMENT form.instance.timestamp = timezone.now() response = super().form_valid(form) @@ -63,13 +69,13 @@ class CommentFormView(DelegatedViewMixin, CreateView): MESSAGES.COMMENT, request=self.request, user=self.request.user, - submission=self.object.submission, + source=source, related=self.object, ) return response def get_success_url(self): - return self.object.submission.get_absolute_url() + '#communications' + return self.object.source.get_absolute_url() + '#communications' @classmethod def contribute_form(cls, instance, user): diff --git a/opentech/apply/dashboard/templates/dashboard/dashboard.html b/opentech/apply/dashboard/templates/dashboard/dashboard.html index 5b0b059bd62e8666a9e0e0273986f71e16957d1e..f4c6282fb88d132ddd9545605d1ad19249b180ec 100644 --- a/opentech/apply/dashboard/templates/dashboard/dashboard.html +++ b/opentech/apply/dashboard/templates/dashboard/dashboard.html @@ -22,6 +22,29 @@ </div> <div class="wrapper wrapper--large wrapper--inner-space-medium"> + + <!-- Example Stat Block markup + <div class="wrapper wrapper--bottom-space"> + <div class="stat-block"> + <a href="#" class="stat-block__item"> + <p class="stat-block__number">3</p> + <p class="stat-block__text">Submissions waiting for your review</p> + <div class="stat-block__view">View</div> + </a> + <a href="#" class="stat-block__item"> + <p class="stat-block__number">10</p> + <p class="stat-block__text">Live projects under your management</p> + <div class="stat-block__view">View</div> + </a> + <a href="#" class="stat-block__item"> + <p class="stat-block__number">4</p> + <p class="stat-block__text">Requests for payment requiring your attention</p> + <div class="stat-block__view">View</div> + </a> + </div> + </div> + --> + <div class="wrapper wrapper--bottom-space"> {% include "dashboard/includes/waiting-for-review.html" with in_review_count=in_review_count my_review=my_review display_more=display_more active_statuses_filter=active_statuses_filter %} </div> diff --git a/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html b/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html index 1e5731de0e367c927aa2607f3a5e28d8dcf49ed9..28d896f8873eb9a15a615eedafd67607542f35a1 100644 --- a/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html +++ b/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html @@ -1,4 +1,4 @@ -<div class="modal modal--secondary" id="batch-send-determination"> +<div class="modal" id="batch-send-determination"> <h4 class="modal__header-bar modal__header-bar--no-bottom-space">Send determination</h4> <div class="modal__copy"> <p>This determination message will be emailed to {{ count }} applicants and <strong>cannot be undone.</strong></p> diff --git a/opentech/apply/determinations/tests/test_views.py b/opentech/apply/determinations/tests/test_views.py index ffb9486fb40840569d5ff05bc4507215dd56d71e..03ead29bd2675e75e9b32f1a4e4f0b60509c6aa3 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 @@ -87,6 +87,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) @@ -94,6 +95,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) @@ -125,6 +133,143 @@ class DeterminationFormTestCase(BaseViewTestCase): self.assertEqual(submission_original.status, 'invited_to_proposal') self.assertEqual(submission_next.status, 'draft_proposal') + def test_first_stage_accepted_determination_does_not_create_project(self): + submission = ApplicationSubmissionFactory( + status='concept_review_discussion', + workflow_stages=2, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': ACCEPTED, + 'message': 'You are invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + self.assertIsNotNone(submission_next) + + # Confirm a Project was not created for either submission since it's + # not in the final stage of its workflow. + self.assertFalse(hasattr(submission_original, 'project')) + self.assertFalse(hasattr(submission_next, 'project')) + + def test_first_stage_rejected_determination_does_not_create_project(self): + submission = ApplicationSubmissionFactory( + status='concept_review_discussion', + workflow_stages=2, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': REJECTED, + 'message': 'You are not invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + # There should be no next submission since rejected is an end to the + # applications flow. + self.assertIsNone(submission_next) + + # Confirm a Project was not created for the original + # ApplicationSubmission. + self.assertFalse(hasattr(submission_original, 'project')) + + def test_second_stage_accepted_determination_creates_project(self): + submission = ApplicationSubmissionFactory( + status='proposal_determination', + workflow_stages=2, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': ACCEPTED, + 'message': 'You are invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + # import ipdb;ipdb.set_trace() + + # There should be no next submission since rejected is an end to the + # applications flow. + self.assertIsNone(submission_next) + + self.assertTrue(hasattr(submission_original, 'project')) + self.assertFalse(hasattr(submission_next, 'project')) + + def test_second_stage_rejected_determination_does_not_create_project(self): + submission = ApplicationSubmissionFactory( + status='proposal_determination', + workflow_stages=2, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': REJECTED, + 'message': 'You are not invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + # There should be no next submission since rejected is an end to the + # applications flow. + self.assertIsNone(submission_next) + + self.assertFalse(hasattr(submission_original, 'project')) + + def test_single_stage_accepted_determination_creates_project(self): + submission = ApplicationSubmissionFactory( + status='post_review_discussion', + workflow_stages=1, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': ACCEPTED, + 'message': 'You are invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + self.assertIsNone(submission_next) + self.assertTrue(hasattr(submission_original, 'project')) + + def test_single_stage_rejected_determination_does_not_create_project(self): + submission = ApplicationSubmissionFactory( + status='post_review_discussion', + workflow_stages=1, + lead=self.user, + ) + + self.post_page(submission, { + 'data': 'value', + 'outcome': REJECTED, + 'message': 'You are not invited to submit a proposal', + }, 'form') + + # Cant use refresh from DB with FSM + submission_original = self.refresh(submission) + submission_next = submission_original.next + + self.assertIsNone(submission_next) + self.assertFalse(hasattr(submission_original, 'project')) + class BatchDeterminationTestCase(BaseViewTestCase): user_factory = StaffFactory diff --git a/opentech/apply/determinations/views.py b/opentech/apply/determinations/views.py index a084214b17544cfcdbb9c9150d05852e2968fd7b..260e6eafa576650c947bf175e7f426cc37894507 100644 --- a/opentech/apply/determinations/views.py +++ b/opentech/apply/determinations/views.py @@ -3,6 +3,7 @@ from urllib import parse from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied +from django.db import transaction from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy @@ -15,6 +16,7 @@ from opentech.apply.activity.models import Activity from opentech.apply.activity.messaging import messenger, MESSAGES from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.funds.workflow import DETERMINATION_OUTCOMES +from opentech.apply.projects.models import Project from opentech.apply.utils.views import CreateOrUpdateView, ViewDispatcher from opentech.apply.users.decorators import staff_required @@ -118,7 +120,7 @@ class BatchDeterminationCreateView(CreateView): MESSAGES.BATCH_DETERMINATION_OUTCOME, request=self.request, user=self.request.user, - submissions=submissions.filter(id__in=list(determinations)), + sources=submissions.filter(id__in=list(determinations)), related=determinations, ) @@ -242,7 +244,10 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): def form_valid(self, form): super().form_valid(form) - if not self.object.is_draft: + if self.object.is_draft: + return HttpResponseRedirect(self.submission.get_absolute_url()) + + with transaction.atomic(): messenger( MESSAGES.DETERMINATION_OUTCOME, request=self.request, @@ -251,6 +256,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): related=self.object, ) proposal_form = form.cleaned_data.get('proposal_form') + transition = transition_from_outcome(form.cleaned_data.get('outcome'), self.submission) if self.object.outcome == NEEDS_MORE_INFO: @@ -259,12 +265,36 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): message=self.object.stripped_message, timestamp=timezone.now(), user=self.request.user, - submission=self.submission, + source=self.submission, related_object=self.object, ) self.submission.perform_transition( - transition, self.request.user, request=self.request, notify=False, proposal_form=proposal_form) + transition, + self.request.user, + request=self.request, + notify=False, + proposal_form=proposal_form, + ) + + if self.submission.accepted_for_funding: + project = Project.create_from_submission(self.submission) + if project: + messenger( + MESSAGES.CREATED_PROJECT, + request=self.request, + user=self.request.user, + source=project, + related=project.submission, + ) + + messenger( + MESSAGES.DETERMINATION_OUTCOME, + request=self.request, + user=self.object.author, + source=self.object.submission, + related=self.object, + ) return HttpResponseRedirect(self.submission.get_absolute_url()) diff --git a/opentech/apply/funds/api_views.py b/opentech/apply/funds/api_views.py index bb406b79326dc578b2214530d77aea315bd79208..bb09838102064ee24e79d8147cc0f5226c147728 100644 --- a/opentech/apply/funds/api_views.py +++ b/opentech/apply/funds/api_views.py @@ -163,7 +163,7 @@ class CommentFilter(filters.FilterSet): class AllCommentFilter(CommentFilter): class Meta(CommentFilter.Meta): - fields = CommentFilter.Meta.fields + ['submission'] + fields = CommentFilter.Meta.fields + ['source_object_id'] class CommentList(generics.ListAPIView): @@ -200,13 +200,13 @@ class CommentListCreate(generics.ListCreateAPIView): timestamp=timezone.now(), type=COMMENT, user=self.request.user, - submission_id=self.kwargs['pk'] + source=ApplicationSubmission.objects.get(pk=self.kwargs['pk']) ) messenger( MESSAGES.COMMENT, request=self.request, user=self.request.user, - submission=obj.submission, + source=obj.submission, related=obj, ) diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py index bec9b723dbd973e0a47b979aa320d28387084fe9..c0cbb6e2d4a8da9fb74ad1df3df461d53343f3e1 100644 --- a/opentech/apply/funds/blocks.py +++ b/opentech/apply/funds/blocks.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from wagtail.core import blocks -from addressfield.fields import AddressField +from addressfield.fields import AddressField, ADDRESS_FIELDS_ORDER from opentech.apply.categories.blocks import CategoryQuestionBlock from opentech.apply.stream_forms.blocks import FormFieldsBlock from opentech.apply.utils.blocks import ( @@ -69,18 +69,15 @@ class AddressFieldBlock(ApplicationSingleIncludeFieldBlock): # Based on the fields listed in addressfields/widgets.py return ', '.join( data[field] - for field in order_fields + for field in ADDRESS_FIELDS_ORDER if data[field] ) def prepare_data(self, value, data, serialize): - order_fields = [ - 'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country' - ] data = json.loads(data) data = { field: data[field] - for field in order_fields + for field in ADDRESS_FIELDS_ORDER } if serialize: 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 a0ca44d8b24a43835bf03d020c243c700cd93f9b..03c943107ea9354dad440de83d68a9b5ac8a96cb 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.safestring 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 a932968d7d56ff5702ee9d93ae21f62c8c4f98a2..d20669b5692dfd6769156d559799fa6078e82672 100644 --- a/opentech/apply/funds/models/submissions.py +++ b/opentech/apply/funds/models/submissions.py @@ -1,7 +1,7 @@ -import os from functools import partialmethod from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.contrib.postgres.fields import JSONField @@ -59,6 +59,7 @@ from ..workflow import ( get_review_active_statuses, INITIAL_STATE, PHASES, + PHASES_MAPPING, review_statuses, STAGE_CHANGE_ACTIONS, UserPermissions, @@ -140,7 +141,7 @@ class ApplicationSubmissionQueryset(JSONOrderable): return self.exclude(next__isnull=False) def with_latest_update(self): - activities = self.model.activities.field.model + activities = self.model.activities.rel.model latest_activity = activities.objects.filter(submission=OuterRef('id')).select_related('user') return self.annotate( last_user_update=Subquery(latest_activity[:1].values('user__full_name')), @@ -148,7 +149,7 @@ class ApplicationSubmissionQueryset(JSONOrderable): ) def for_table(self, user): - activities = self.model.activities.field.model + activities = self.model.activities.rel.model comments = activities.comments.filter(submission=OuterRef('id')).visible_to(user) roles_for_review = self.model.assigned.field.model.objects.with_roles().filter( submission=OuterRef('id'), reviewer=user) @@ -199,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' ), @@ -209,7 +212,6 @@ class ApplicationSubmissionQueryset(JSONOrderable): queryset=AssignedReviewers.objects.not_reviewed().staff(), to_attr='hasnt_reviewed' ) - ).select_related( 'page', 'round', @@ -217,6 +219,7 @@ class ApplicationSubmissionQueryset(JSONOrderable): 'user', 'previous__page', 'previous__round', + 'previous__lead', ) @@ -396,6 +399,12 @@ class ApplicationSubmission( related_name='submissions', blank=True, ) + activities = GenericRelation( + 'activity.Activity', + content_type_field='source_content_type', + object_id_field='source_object_id', + related_query_name='submission', + ) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) search_data = models.TextField() @@ -532,7 +541,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): @@ -581,13 +590,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): @@ -703,6 +711,22 @@ class ApplicationSubmission( def __repr__(self): return f'<{self.__class__.__name__}: {self.user}, {self.round}, {self.page}>' + @property + def accepted_for_funding(self): + accepted = self.status in PHASES_MAPPING['accepted']['statuses'] + return self.in_final_stage and accepted + + @property + def in_final_stage(self): + stages = self.workflow.stages + + stage_index = stages.index(self.stage) + + # adjust the index since list.index() is zero-based + adjusted_index = stage_index + 1 + + return adjusted_index == len(stages) + # Methods for accessing data on the submission def get_data(self): @@ -736,7 +760,7 @@ def log_status_update(sender, **kwargs): MESSAGES.TRANSITION, user=by, request=request, - submission=instance, + source=instance, related=old_phase, ) @@ -745,7 +769,7 @@ def log_status_update(sender, **kwargs): MESSAGES.READY_FOR_REVIEW, user=by, request=request, - submission=instance, + source=instance, ) if instance.status in STAGE_CHANGE_ACTIONS: @@ -753,7 +777,7 @@ def log_status_update(sender, **kwargs): MESSAGES.INVITED_TO_PROPOSAL, request=request, user=by, - submission=instance, + source=instance, ) diff --git a/opentech/apply/funds/models/utils.py b/opentech/apply/funds/models/utils.py index c5131399e0b82001dccd59200314786e91e029bc..582a1df10f2682a4de9fe9b50c3dc542b94af5f7 100644 --- a/opentech/apply/funds/models/utils.py +++ b/opentech/apply/funds/models/utils.py @@ -97,7 +97,7 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm): # type: ignore MESSAGES.NEW_SUBMISSION, request=request, user=form_submission.user, - submission=form_submission, + source=form_submission, ) return super().render_landing_page(request, form_submission=None, *args, **kwargs) diff --git a/opentech/apply/funds/serializers.py b/opentech/apply/funds/serializers.py index 54478823b417cab8b1a3c054b7fac2f6a3d0a0a7..cd828247a3e32144b6a4d6066e2f76d7de0b8b80 100644 --- a/opentech/apply/funds/serializers.py +++ b/opentech/apply/funds/serializers.py @@ -208,7 +208,7 @@ class CommentSerializer(serializers.ModelSerializer): class Meta: model = Activity - fields = ('id', 'timestamp', 'user', 'submission', 'message', 'visibility', 'edited', 'edit_url', 'editable') + fields = ('id', 'timestamp', 'user', 'source', 'message', 'visibility', 'edited', 'edit_url', 'editable') def get_message(self, obj): return bleach_value(markdown(obj.message)) diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html index 2e9404be49b4089f9dfbbdd25485af922ecc6d31..4bea7c49f804685720f6b8de0ccd94ec03193d06 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -6,18 +6,26 @@ {{ reviewer_form.media.css }} {% endblock %} -{% block admin_actions %} - {% include "funds/includes/actions.html" with mobile=True %} +{% block mobile_actions %} + <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a> + <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions sidebar__inner--mobile"> + {% include "funds/includes/actions.html" %} + </div> {% endblock %} -{% block sidebar_forms %} +{% block sidebar_top %} + <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> {% include "funds/includes/actions.html" %} - {% include "funds/includes/screening_form.html" %} - {% include "funds/includes/progress_form.html" %} - {% include "funds/includes/update_lead_form.html" %} - {% include "funds/includes/update_reviewer_form.html" %} - {% include "funds/includes/update_partner_form.html" %} - {% include "funds/includes/update_meta_categories_form.html" %} + </div> + {% include "funds/includes/screening_form.html" %} + {% include "funds/includes/progress_form.html" %} + {% include "funds/includes/update_lead_form.html" %} + {% include "funds/includes/update_reviewer_form.html" %} + {% include "funds/includes/update_partner_form.html" %} + {% if PROJECTS_ENABLED %} + {% include "funds/includes/create_project_form.html" %} + {% endif %} + {% include "funds/includes/update_meta_categories_form.html" %} {% endblock %} {% block reviews %} @@ -61,4 +69,6 @@ <script src="{% static 'js/apply/submission-text-cleanup.js' %}"></script> <script src="{% static 'js/apply/toggle-related.js' %}"></script> <script src="{% static 'js/apply/edit-comment.js' %}"></script> + <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script> + <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script> {% endblock %} diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html index dd63c6757300488f5209cd59860f772af197e0a2..7c997b86d74e10ee61053915661e78f5ae7a6f8e 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html @@ -53,7 +53,7 @@ <div class="wrapper wrapper--large wrapper--tabs js-tabs-content"> {# Tab 1 #} <div class="tabs__content" id="tab-1"> - {% block admin_actions %} + {% block mobile_actions %} {% endblock %} <div class="wrapper wrapper--sidebar"> {% if request.user|has_edit_perm:object and object.status == 'draft_proposal' and not request.user.is_apply_staff %} @@ -67,45 +67,48 @@ <header class="heading heading--submission-meta heading-text zeta"> <span>Submitted: <strong>{{ object.submit_time.date }} by {{ object.user.get_full_name }}</strong></span> <span>Last edited: <strong>{{ object.live_revision.timestamp.date }} by {{ object.live_revision.author }}</strong></span> - {% if perms.funds.delete_applicationsubmission %} - <a class="link link--delete-submission is-active" href="{% url 'funds:submissions:delete' object.id %}"> - Delete - <svg class="icon icon--delete"><use xlink:href="#delete"></use></svg> - </a> - {% endif %} - {% if request.user|has_edit_perm:object %} - <a class="link link--edit-submission is-active" href="{% url 'funds:submissions:edit' object.id %}"> - Edit - <svg class="icon icon--pen"><use xlink:href="#pen"></use></svg> - </a> - {% else %} - <span class="link link--edit-submission"> - Edit - <svg class="icon icon--padlock"><use xlink:href="#padlock"></use></svg> - </span> - {% endif %} + <div class="wrapper wrapper--submission-actions"> + {% if perms.funds.delete_applicationsubmission %} + <a class="link link--delete-submission is-active" href="{% url 'funds:submissions:delete' object.id %}"> + Delete + <svg class="icon icon--delete"><use xlink:href="#delete"></use></svg> + </a> + {% endif %} + {% if request.user|has_edit_perm:object %} + <a class="link link--edit-submission is-active" href="{% url 'funds:submissions:edit' object.id %}"> + Edit + <svg class="icon icon--pen"><use xlink:href="#pen"></use></svg> + </a> + {% else %} + <span class="link link--edit-submission"> + Edit + <svg class="icon icon--padlock"><use xlink:href="#padlock"></use></svg> + </span> + {% endif %} + </div> </header> {% include "funds/includes/rendered_answers.html" %} + </article> {% endif %} <aside class="sidebar"> - {% if request.user.is_apply_staff %} - {% block sidebar_forms %} - {% endblock %} + {% block sidebar_top %} + {% endblock %} + + {% if object.project and PROJECTS_ENABLED %} + {% include 'funds/includes/project_block.html' %} {% endif %} {% block determination %} {% include 'determinations/includes/applicant_determination_block.html' with submission=object %} {% endblock %} - {% if request.user.is_apply_staff %} - {% block screening_status %} - {% endblock %} - {% block meta_categories %} - {% endblock %} - {% endif %} + {% block screening_status %} + {% endblock %} + {% block meta_categories %} + {% endblock %} {% block reviews %} {% endblock %} diff --git a/opentech/apply/funds/templates/funds/email/confirmation.html b/opentech/apply/funds/templates/funds/email/confirmation.html index b02d696032eedb44d160264209ae517455a3bb3b..b13a56bc529f5ed4a9572a04460150adb0fea82d 100644 --- a/opentech/apply/funds/templates/funds/email/confirmation.html +++ b/opentech/apply/funds/templates/funds/email/confirmation.html @@ -1,11 +1,11 @@ {% extends "messages/email/base.html" %} -{% block content %}{% with email_context=submission.page.specific %}We appreciate your {{ email_context.title }} application submission to the {{ ORG_LONG_NAME }}. We will review and reply to your submission as quickly as possible. +{% block content %}{% with email_context=source.page.specific %}We appreciate your {{ email_context.title }} application submission to the {{ ORG_LONG_NAME }}. We will review and reply to your submission as quickly as possible. Our reply will have the next steps for your {{ email_context.title }} application. You can find more information about our support options, review process and selection criteria on our website. {{ email_context.confirmation_text_text }}{% endwith %} -Project name: {{ submission.title }} -Contact name: {{ submission.user.get_full_name }} -Contact email: {{ submission.user.email }}{% endblock %} +Project name: {{ source.title }} +Contact name: {{ source.user.get_full_name }} +Contact email: {{ source.user.email }}{% endblock %} diff --git a/opentech/apply/funds/templates/funds/includes/actions.html b/opentech/apply/funds/templates/funds/includes/actions.html index 7850e045346c675a8590b4ca63a894458a8593d4..cfd7665631b18b7448da129f75ce1fb059be85ec 100644 --- a/opentech/apply/funds/templates/funds/includes/actions.html +++ b/opentech/apply/funds/templates/funds/includes/actions.html @@ -1,24 +1,28 @@ -{% if mobile %} - <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a> -{% endif %} - -<div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> - <h5>Actions to take</h5> +<h5>Actions to take</h5> - <a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a> - <a data-fancybox data-src="#update-status" class="button button--primary button--full-width {% if progress_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Update status</a> +{% if PROJECTS_ENABLED %} +<a data-fancybox + data-src="#create-project" + class="button button--bottom-space button--primary button--full-width {% if object.accepted_for_funding and not object.project %}is-not-disabled{% else %}is-disabled{% endif %}" + href="#"> + Create Project +</a> +{% endif %} +<a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a> - <p class="sidebar__separator">Assign</p> +<a data-fancybox data-src="#update-status" class="button button--primary button--full-width {% if progress_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Update status</a> - <div class="wrapper wrapper--sidebar-buttons"> - <a data-fancybox data-src="#update-reviewers" class="button button--half-width button--white" href="#">Reviewers</a> - <a data-fancybox data-src="#update-partners" class="button button--half-width button--white" href="#">Partners</a> - <a data-fancybox data-src="#assign-lead" class="button button--half-width button--white" href="#">Lead</a> - </div> - <a class="button button--white button--full-width button--bottom-space" href="{% url 'funds:submissions:revisions:list' submission_pk=object.id %}">Revisions</a> +<p class="sidebar__separator">Assign</p> - <a data-fancybox data-src="#update-meta-categories" class="button button--white button--full-width button--bottom-space" href="#">Meta Categories</a> +<div class="wrapper wrapper--sidebar-buttons"> + <a data-fancybox data-src="#update-reviewers" class="button button--half-width button--white" href="#">Reviewers</a> + <a data-fancybox data-src="#update-partners" class="button button--half-width button--white" href="#">Partners</a> + <a data-fancybox data-src="#assign-lead" class="button button--half-width button--white" href="#">Lead</a> </div> + +<a class="button button--white button--full-width button--bottom-space" href="{% url 'funds:submissions:revisions:list' submission_pk=object.id %}">Revisions</a> + +<a data-fancybox data-src="#update-meta-categories" class="button button--white button--full-width button--bottom-space" href="#">Meta Categories</a> diff --git a/opentech/apply/funds/templates/funds/includes/batch_progress_form.html b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html index 59d98b3f1687e66f7d0c66937cd177b83013d4cb..7e02a8075d0dde0b2253bbe0dabfc65f11491add 100644 --- a/opentech/apply/funds/templates/funds/includes/batch_progress_form.html +++ b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html @@ -1,4 +1,4 @@ -<div class="modal modal--secondary" id="batch-progress"> +<div class="modal" id="batch-progress"> <h4 class="modal__header-bar modal__header-bar--no-bottom-space">Update Status</h4> <div class="modal__list-item modal__list-item--meta" aria-live="polite"> <span class="js-batch-title-count"></span> diff --git a/opentech/apply/funds/templates/funds/includes/create_project_form.html b/opentech/apply/funds/templates/funds/includes/create_project_form.html new file mode 100644 index 0000000000000000000000000000000000000000..f7b10c87b7925af311aea81c128017fce5b1dddd --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/create_project_form.html @@ -0,0 +1,4 @@ +<div class="modal" id="create-project"> + <h4 class="modal__header-bar">Create Project</h4> + {% include 'funds/includes/delegated_form_base.html' with form=project_form value='Create'%} +</div> diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html index c6f0746dc01080d2b61ae9874c63155cb935f8c0..4ed3da58223d879cda16744876e5288eb8cad3bf 100644 --- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html +++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html @@ -1,10 +1,40 @@ {% load util_tags %} -<form class="form {{extra_classes}}" method="post" id="{{ form.name }}"> +<form + class="form {{ extra_classes }}" + method="post" + id="{{ form.name }}" + enctype="multipart/form-data" + {% if action %}action="{{ action }}"{% endif %} + > {% csrf_token %} - <div class="form__item"> - {{ form }} - </div> - <input class="button button--primary button--top-space" id="{{ form.name }}-submit" name="{{ form_prefix }}{{ form.name }}" type="submit" form="{{ form.name }}" value="{{ value }}"> + {{ form.media }} + + {% for field in form %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + + {% if cancel %} + <button + type="button" + data-fancybox-close="" + class="button button--{% if invert %}primary{% else %}white{% endif %} button--top-space" + title="Close"> + Cancel + </button> + {% endif %} + + <input + class="button button--{% if invert %}white{% else %}primary{% endif %} button--top-space" + id="{{ form.name }}-submit" + name="{{ form_prefix }}{{ form.name }}" + type="submit" + form="{{ form.name }}" + value="{{ value }}" + > </form> diff --git a/opentech/apply/funds/templates/funds/includes/funding_block.html b/opentech/apply/funds/templates/funds/includes/funding_block.html new file mode 100644 index 0000000000000000000000000000000000000000..c507b379fe44078dfa14260ebca6ef8fcbeae13c --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/funding_block.html @@ -0,0 +1,16 @@ +<ul class="funding-block"> + <li class="funding-block__item"> + <p class="funding-block__title">Fund total</p> + <p class="funding-block__standout">$50,000</p> + </li> + <li class="funding-block__item"> + <p class="funding-block__title">Total paid</p> + <p class="funding-block__standout">$2,000</p> + <p class="funding-block__meta">(4%)</p> + </li> + <li class="funding-block__item"> + <p class="funding-block__title">Awaiting payment</p> + <p class="funding-block__standout">$10,000</p> + <p class="funding-block__meta">(20%)</p> + </li> +</ul> diff --git a/opentech/apply/funds/templates/funds/includes/invoice_block.html b/opentech/apply/funds/templates/funds/includes/invoice_block.html new file mode 100644 index 0000000000000000000000000000000000000000..fdec65d18fa88878fdde1b758cfa0b607ae6df68 --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/invoice_block.html @@ -0,0 +1,14 @@ +<ul class="invoice-block"> + <li class="invoice-block__item"> + <p class="invoice-block__title">Contract number</p> + <p class="invoice-block__meta">XXXXXX</p> + </li> + <li class="invoice-block__item"> + <p class="invoice-block__title">Pay to</p> + <p class="invoice-block__meta">XXXXXX</p> + </li> + <li class="invoice-block__item"> + <p class="invoice-block__title">Some details</p> + <p class="invoice-block__meta">XXXXXX</p> + </li> +</ul> diff --git a/opentech/apply/funds/templates/funds/includes/payment_requests.html b/opentech/apply/funds/templates/funds/includes/payment_requests.html new file mode 100644 index 0000000000000000000000000000000000000000..d344d17f9539db00ce09052968fdeba6e3239905 --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/payment_requests.html @@ -0,0 +1,79 @@ +<div class="payment-block"> + <div class="payment-block__header"> + <p class="payment-block__title">Payment Requests</p> + <button class="payment-block__button button button--primary">Add Request</button> + </div> + + <table class="payment-block__table"> + <thead> + <tr> + <th class="payment-block__table-amount">Amount</th> + <th class="payment-block__table-status">Status</th> + <th class="payment-block__table-docs">Documents</th> + <th class="payment-block__table-update">Status</th> + </tr> + </thead> + <tbody> + <tr> + <td><span class="payment-block__mobile-label">Amount: </span>$10,000</td> + <td><span class="payment-block__mobile-label">Status: </span>Approved 1111-11-11</td> + <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> + <td> + <span class="payment-block__mobile-label">Status:</span> + <span class="payment-block__status">Approved</span> + <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a> + </td> + </tr> + <tr> + <td><span class="payment-block__mobile-label">Amount: </span>$1,000</td> + <td><span class="payment-block__mobile-label">Status: </span>Approved 2222-11-11</td> + <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> + <td> + <span class="payment-block__mobile-label">Status:</span> + <span class="payment-block__status">Paid</span> + <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a> + </td> + </tr> + </tbody> + </table> + <p class="payment-block__rejected"><a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a></p> + + <table class="payment-block__table is-hidden js-payment-block-rejected-table"> + <thead> + <tr> + <th class="payment-block__table-amount">Amount</th> + <th class="payment-block__table-status">Status</th> + <th class="payment-block__table-docs">Documents</th> + <th class="payment-block__table-update">Status</th> + </tr> + </thead> + <tbody> + <tr> + <td><span class="payment-block__mobile-label">Amount: </span>$14,000</td> + <td><span class="payment-block__mobile-label">Status: </span>Rejected 1111-11-11</td> + <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> + <td> + <span class="payment-block__mobile-label">Status:</span> + <span class="payment-block__status">Rejected</span> + </td> + </tr> + <tr> + <td><span class="payment-block__mobile-label">Amount: </span>$105,000</td> + <td><span class="payment-block__mobile-label">Status: </span>Rejected 2222-11-11</td> + <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> + <td> + <span class="payment-block__mobile-label">Status:</span> + <span class="payment-block__status">Rejected</span> + </td> + </tr> + </tbody> + </table> +</div> + +<div class="modal" id="change-payment-status"> + <h4 class="modal__header-bar">Change payment status</h4> + <div class="wrapper--outer-space-medium"> + <p>Current status: <b>Approved</b></p> + {# {% include 'funds/includes/delegated_form_base.html' with form=some_form value='Update'%} #} + </div> +</div> diff --git a/opentech/apply/funds/templates/funds/includes/project_block.html b/opentech/apply/funds/templates/funds/includes/project_block.html new file mode 100644 index 0000000000000000000000000000000000000000..31041e55557fd2a65f236696c83f15a95caddd94 --- /dev/null +++ b/opentech/apply/funds/templates/funds/includes/project_block.html @@ -0,0 +1,6 @@ +<div class="sidebar__inner"> + <h5>Project</h5> + <p> + <a href="{% url 'apply:projects:detail' pk=object.project.id %}">{{ object.project.title }}</a> + </p> +</div> diff --git a/opentech/apply/funds/templates/funds/includes/rendered_answers.html b/opentech/apply/funds/templates/funds/includes/rendered_answers.html index 301e1820b640b35948f92e4f315f31917e038c6a..78fee6416cde93229f9002b89e4aa3f1b35f4978 100644 --- a/opentech/apply/funds/templates/funds/includes/rendered_answers.html +++ b/opentech/apply/funds/templates/funds/includes/rendered_answers.html @@ -25,6 +25,7 @@ {{ object.get_address_display }} </div> </div> + <div class="rich-text rich-text--answers"> {{ object.output_answers }} </div> 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_models.py b/opentech/apply/funds/tests/test_models.py index eaa22338c1c81d12aabff0d25395dc18916a5c1f..a09447647fddc3965fcf11aa067f931a308a69f7 100644 --- a/opentech/apply/funds/tests/test_models.py +++ b/opentech/apply/funds/tests/test_models.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core import mail from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from django.urls import reverse from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.funds.blocks import EmailBlock, FullNameBlock @@ -22,6 +23,7 @@ from .factories import ( AssignedReviewersFactory, CustomFormFieldsFactory, FundTypeFactory, + InvitedToProposalFactory, LabFactory, RequestForPartnersFactory, RoundFactory, @@ -452,11 +454,19 @@ class TestApplicationSubmission(TestCase): submission.create_revision(draft=True) self.assertEqual(submission.revisions.count(), 2) + def test_in_final_stage(self): + submission = InvitedToProposalFactory().previous + self.assertFalse(submission.in_final_stage) + submission = InvitedToProposalFactory() + self.assertTrue(submission.in_final_stage) + + +@override_settings(ROOT_URLCONF='opentech.apply.urls') class TestSubmissionRenderMethods(TestCase): def test_named_blocks_not_included_in_answers(self): submission = ApplicationSubmissionFactory() - answers = submission.render_answers() + answers = submission.output_answers() for name in submission.named_blocks: field = submission.field(name) self.assertNotIn(field.value['field_label'], answers) @@ -474,7 +484,7 @@ class TestSubmissionRenderMethods(TestCase): submission = ApplicationSubmissionFactory( form_fields__text_markup__value=rich_text_label ) - answers = submission.render_answers() + answers = submission.output_answers() self.assertNotIn(rich_text_label, answers) def test_named_blocks_dont_break_if_no_response(self): @@ -484,6 +494,28 @@ class TestSubmissionRenderMethods(TestCase): self.assertTrue('value' not in submission.raw_data) self.assertTrue('duration' in submission.raw_data) + def test_file_private_url_included(self): + submission = ApplicationSubmissionFactory() + answers = submission.output_answers() + for file_id in submission.file_field_ids: + + def file_url_in_answers(file_to_test): + url = reverse( + 'apply:submissions:serve_private_media', kwargs={ + 'pk': submission.pk, + 'field_id': file_id, + 'file_name': file_to_test.basename, + } + ) + self.assertIn(url, answers) + + file_response = submission.data(file_id) + if isinstance(file_response, list): + for stream_file in file_response: + file_url_in_answers(stream_file) + else: + file_url_in_answers(file_response) + class TestRequestForPartners(TestCase): def test_message_when_no_round(self): @@ -581,7 +613,7 @@ class TestForTableQueryset(TestCase): ReviewFactory(submission=submission_two) - qs = ApplicationSubmission.objects.for_table(user=staff) + qs = ApplicationSubmission.objects.order_by('pk').for_table(user=staff) submission = qs[0] self.assertEqual(submission, submission_one) self.assertEqual(submission.opinion_disagree, 1) diff --git a/opentech/apply/funds/tests/test_views.py b/opentech/apply/funds/tests/test_views.py index a51902eb5669be0ce1c1580b80ac7d719277370e..b5a7b0235a4270049372f36b0ff003a134e214a1 100644 --- a/opentech/apply/funds/tests/test_views.py +++ b/opentech/apply/funds/tests/test_views.py @@ -1,10 +1,13 @@ from datetime import timedelta import json +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse from django.utils import timezone from django.utils.text import slugify from opentech.apply.activity.models import Activity, TEAM +from opentech.apply.projects.models import Project from opentech.apply.determinations.tests.factories import DeterminationFactory from opentech.apply.funds.tests.factories import ( ApplicationSubmissionFactory, @@ -29,7 +32,7 @@ from opentech.apply.users.tests.factories import ( from opentech.apply.utils.testing import make_request from opentech.apply.utils.testing.tests import BaseViewTestCase -from ..models import ApplicationRevision +from ..models import ApplicationRevision, ApplicationSubmission def prepare_form_data(submission, **kwargs): @@ -198,6 +201,22 @@ class TestStaffSubmissionView(BaseSubmissionViewTestCase): response = self.get_page(self.submission) self.assertContains(response, 'Screening Status') + def test_can_create_project(self): + # check submission doesn't already have a Project + with self.assertRaisesMessage(Project.DoesNotExist, 'ApplicationSubmission has no project.'): + self.submission.project + + self.post_page(self.submission, { + 'form-submitted-project_form': '', + 'submission': self.submission.id, + }) + + project = Project.objects.order_by('-pk').first() + submission = ApplicationSubmission.objects.get(pk=self.submission.pk) + + self.assertTrue(hasattr(submission, 'project')) + self.assertEquals(submission.project.id, project.id) + class TestReviewersUpdateView(BaseSubmissionViewTestCase): user_factory = StaffFactory @@ -641,3 +660,56 @@ class TestSuperUserSubmissionView(BaseSubmissionViewTestCase): # Check that an activity was created that should only be viewable internally activity = Activity.objects.filter(message__contains='Screening status').first() self.assertEqual(activity.visibility, TEAM) + + +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/tests/views/test_batch_progress.py b/opentech/apply/funds/tests/views/test_batch_progress.py index b7784f49feb9729fbaacdd2a451184521f8d5005..ac56fbdf75af60dd16e30e9f537a715ed7c7bb02 100644 --- a/opentech/apply/funds/tests/views/test_batch_progress.py +++ b/opentech/apply/funds/tests/views/test_batch_progress.py @@ -98,7 +98,7 @@ class StaffTestCase(BaseBatchProgressViewTestCase): self.post_page(data=self.data(action, [submission])) patched.assert_called_once() _, _, kwargs = patched.mock_calls[0] - self.assertQuerysetEqual(kwargs['submissions'], ApplicationSubmission.objects.none()) + self.assertQuerysetEqual(kwargs['sources'], ApplicationSubmission.objects.none()) @mock.patch('opentech.apply.funds.views.messenger') def test_messenger_with_submission_in_review(self, patched): @@ -107,9 +107,9 @@ class StaffTestCase(BaseBatchProgressViewTestCase): self.post_page(data=self.data(action, [submission])) self.assertEqual(patched.call_count, 2) _, _, kwargs = patched.mock_calls[0] - self.assertCountEqual(kwargs['submissions'], [submission]) + self.assertCountEqual(kwargs['sources'], [submission]) _, _, kwargs = patched.mock_calls[1] - self.assertCountEqual(kwargs['submissions'], [submission]) + self.assertCountEqual(kwargs['sources'], [submission]) class ReivewersTestCase(BaseBatchProgressViewTestCase): diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py index 386993422d70d095bd1c10dc336fd9c0616fdc74..4f035de13e61112260e7853140ee7eeaf848a5f3 100644 --- a/opentech/apply/funds/urls.py +++ b/opentech/apply/funds/urls.py @@ -1,5 +1,7 @@ from django.urls import include, path +from opentech.apply.projects import urls as projects_urls + from .views import ( RevisionCompareView, RevisionListView, @@ -37,15 +39,15 @@ 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('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")), @@ -81,5 +83,6 @@ rounds_urls = ([ urlpatterns = [ path('submissions/', include(submission_urls)), path('rounds/', include(rounds_urls)), + 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 b2aeb1d20f2e5aacfa5da90570cdb2ca27791902..d118e93365c94c00f457d729b32e1f0f03fe003f 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.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ -from django.views.generic import 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 @@ -31,11 +26,15 @@ from opentech.apply.activity.views import ( ) from opentech.apply.activity.messaging import messenger, MESSAGES from opentech.apply.determinations.views import BatchDeterminationCreateView, DeterminationCreateOrUpdateView +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, @@ -54,6 +53,7 @@ from .models import ( RoundBase, LabBase ) +from .permissions import is_user_has_access_to_view_submission from .tables import ( AdminSubmissionsTable, ReviewerSubmissionsTable, @@ -64,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): @@ -118,7 +115,7 @@ class BatchUpdateLeadView(DelegatedViewMixin, FormView): MESSAGES.BATCH_UPDATE_LEAD, request=self.request, user=self.request.user, - submissions=submissions, + sources=submissions, new_lead=new_lead, ) return super().form_valid(form) @@ -145,7 +142,7 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView): MESSAGES.BATCH_REVIEWERS_UPDATED, request=self.request, user=self.request.user, - submissions=submissions, + sources=submissions, added=reviewers, ) return super().form_valid(form) @@ -203,7 +200,7 @@ class BatchProgressSubmissionView(DelegatedViewMixin, FormView): MESSAGES.BATCH_TRANSITION, user=self.request.user, request=self.request, - submissions=succeeded_submissions, + sources=succeeded_submissions, related=phase_changes, ) @@ -216,7 +213,7 @@ class BatchProgressSubmissionView(DelegatedViewMixin, FormView): MESSAGES.BATCH_READY_FOR_REVIEW, user=self.request.user, request=self.request, - submissions=succeeded_submissions.filter(status__in=ready_for_review), + sources=succeeded_submissions.filter(status__in=ready_for_review), ) return super().form_valid(form) @@ -374,6 +371,29 @@ class ProgressSubmissionView(DelegatedViewMixin, UpdateView): return super().form_valid(form) +@method_decorator(staff_required, name='dispatch') +class CreateProjectView(DelegatedViewMixin, CreateView): + context_name = 'project_form' + form_class = CreateProjectForm + model = Project + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.CREATED_PROJECT, + request=self.request, + user=self.request.user, + source=self.object, + related=self.object.submission, + ) + + return response + + def get_success_url(self): + return self.object.get_absolute_url() + + @method_decorator(staff_required, name='dispatch') class ScreeningSubmissionView(DelegatedViewMixin, UpdateView): model = ApplicationSubmission @@ -388,7 +408,7 @@ class ScreeningSubmissionView(DelegatedViewMixin, UpdateView): MESSAGES.SCREENING, request=self.request, user=self.request.user, - submission=self.object, + source=self.object, related=str(old.screening_status), ) return response @@ -408,7 +428,7 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView): MESSAGES.UPDATE_LEAD, request=self.request, user=self.request.user, - submission=form.instance, + source=form.instance, related=old.lead, ) return response @@ -435,7 +455,7 @@ class UpdateReviewersView(DelegatedViewMixin, UpdateView): MESSAGES.REVIEWERS_UPDATED, request=self.request, user=self.request.user, - submission=self.kwargs['submission'], + source=self.kwargs['object'], added=added, removed=removed, ) @@ -483,7 +503,7 @@ class UpdatePartnersView(DelegatedViewMixin, UpdateView): MESSAGES.PARTNERS_UPDATED, request=self.request, user=self.request.user, - submission=self.kwargs['submission'], + source=self.kwargs['object'], added=added, removed=removed, ) @@ -492,7 +512,7 @@ class UpdatePartnersView(DelegatedViewMixin, UpdateView): MESSAGES.PARTNERS_UPDATED_PARTNER, request=self.request, user=self.request.user, - submission=self.kwargs['submission'], + source=self.kwargs['object'], added=added, removed=removed, ) @@ -517,6 +537,7 @@ class AdminSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, Delega UpdateLeadView, UpdateReviewersView, UpdatePartnersView, + CreateProjectView, UpdateMetaCategoriesView, ] @@ -636,7 +657,7 @@ class SubmissionSealedView(DetailView): MESSAGES.OPENED_SEALED, request=self.request, user=self.request.user, - submission=submission, + source=submission, ) self.request.session.setdefault('peeked', {})[str(submission.id)] = True # Dictionary updates do not trigger session saves. Force update @@ -714,7 +735,7 @@ class AdminSubmissionEditView(BaseSubmissionEditView): MESSAGES.EDIT, request=self.request, user=self.request.user, - submission=self.object, + source=self.object, related=revision, ) @@ -753,14 +774,14 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView): MESSAGES.PROPOSAL_SUBMITTED, request=self.request, user=self.request.user, - submission=self.object, + source=self.object, ) elif revision: messenger( MESSAGES.APPLICANT_EDIT, request=self.request, user=self.request.user, - submission=self.object, + source=self.object, related=revision, ) @@ -887,51 +908,26 @@ class SubmissionDeleteView(DeleteView): MESSAGES.DELETE_SUBMISSION, user=request.user, request=request, - submission=submission, + source=submission, ) response = super().delete(request, *args, **kwargs) return response -class SubmissionPrivateMediaView(UserPassesTestMixin, View): +@method_decorator(login_required, name='dispatch') +class SubmissionPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + raise_exception = True - def get(self, *args, **kwargs): - submission_id = kwargs['submission_id'] + 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_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) diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py index fd4a251fc1fdea23ce2bece530b6416b36beca24..bb204567ccd1c8e0ffb32551a13996ef73dafd6f 100644 --- a/opentech/apply/funds/workflow.py +++ b/opentech/apply/funds/workflow.py @@ -110,7 +110,7 @@ class Phase: return self.display_name def __repr__(self): - return f'<Phase {self.display_name} ({self.public_name})>' + return f'<Phase: {self.display_name} ({self.public_name})>' class Stage: @@ -121,6 +121,9 @@ class Stage: def __str__(self): return self.name + def __repr__(self): + return f'<Stage: {self.name}>' + class Permissions: def __init__(self, permissions): diff --git a/opentech/apply/projects/__init__.py b/opentech/apply/projects/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/projects/admin.py b/opentech/apply/projects/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..711563188a21bdd91c2e29ea110956626a556f9e --- /dev/null +++ b/opentech/apply/projects/admin.py @@ -0,0 +1,17 @@ +from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup + +from .models import DocumentCategory + + +class DocumentCategoryAdmin(ModelAdmin): + model = DocumentCategory + menu_icon = 'doc-full' + list_display = ('name', 'recommended_minimum',) + + +class ManageAdminGoup(ModelAdminGroup): + menu_label = 'Manage' + menu_icon = 'folder-open-inverse' + items = ( + DocumentCategoryAdmin, + ) diff --git a/opentech/apply/projects/apps.py b/opentech/apply/projects/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..5309c89c193bb7f7d21eb1eaa14f56a9a030792f --- /dev/null +++ b/opentech/apply/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + name = 'opentech.apply.projects' + label = 'application_projects' 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/forms.py b/opentech/apply/projects/forms.py new file mode 100644 index 0000000000000000000000000000000000000000..13d19826fdf490fba6a6f3b2ca7b60e92158837f --- /dev/null +++ b/opentech/apply/projects/forms.py @@ -0,0 +1,136 @@ +from django import forms +from django.db.models import Q + +from addressfield.fields import AddressField +from opentech.apply.funds.models import ApplicationSubmission +from opentech.apply.users.groups import STAFF_GROUP_NAME + +from .models import COMMITTED, Approval, PacketFile, Project + + +class CreateProjectForm(forms.Form): + submission = forms.ModelChoiceField( + queryset=ApplicationSubmission.objects.filter(project__isnull=True), + widget=forms.HiddenInput(), + ) + + def __init__(self, instance=None, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + if instance: + self.fields['submission'].initial = instance.id + + def save(self, *args, **kwargs): + submission = self.cleaned_data['submission'] + return Project.create_from_submission(submission) + + +class CreateApprovalForm(forms.ModelForm): + class Meta: + model = Approval + fields = ['by'] + widgets = {'by': forms.HiddenInput()} + + def __init__(self, user=None, *args, **kwargs): + initial = kwargs.pop('initial', {}) + initial.update(by=user) + super().__init__(*args, initial=initial, **kwargs) + + +class ProjectEditForm(forms.ModelForm): + contact_address = AddressField() + + class Meta: + fields = [ + 'title', + 'contact_legal_name', + 'contact_email', + 'contact_address', + 'contact_phone', + 'value', + 'proposed_start', + 'proposed_end', + ] + model = Project + widgets = { + 'title': forms.TextInput, + 'contact_legal_name': forms.TextInput, + 'contact_email': forms.TextInput, + 'contact_phone': forms.TextInput, + 'proposed_end': forms.DateInput, + 'proposed_start': forms.DateInput, + } + + +class ProjectApprovalForm(ProjectEditForm): + def save(self, *args, **kwargs): + self.instance.user_has_updated_details = True + return super().save(*args, **kwargs) + + +class RejectionForm(forms.Form): + comment = forms.CharField(widget=forms.Textarea) + + def __init__(self, instance=None, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class RemoveDocumentForm(forms.ModelForm): + id = forms.IntegerField(widget=forms.HiddenInput()) + + class Meta: + fields = ['id'] + model = PacketFile + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class SetPendingForm(forms.ModelForm): + class Meta: + fields = ['id'] + model = Project + widgets = {'id': forms.HiddenInput()} + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + def clean(self): + if self.instance.status != COMMITTED: + raise forms.ValidationError('A Project can only be sent for Approval when Committed.') + + if self.instance.is_locked: + raise forms.ValidationError('A Project can only be sent for Approval once') + + super().clean() + + def save(self, *args, **kwargs): + self.instance.is_locked = True + return super().save(*args, **kwargs) + + +class UploadDocumentForm(forms.ModelForm): + class Meta: + fields = ['title', 'category', 'document'] + model = PacketFile + widgets = {'title': forms.TextInput()} + + def __init__(self, user=None, instance=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class UpdateProjectLeadForm(forms.ModelForm): + class Meta: + fields = ['lead'] + model = Project + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + lead_field = self.fields['lead'] + lead_field.label = f'Update lead from {self.instance.lead} to' + + qwargs = Q(groups__name=STAFF_GROUP_NAME) | Q(is_superuser=True) + lead_field.queryset = (lead_field.queryset.exclude(pk=self.instance.lead_id) + .filter(qwargs) + .distinct()) diff --git a/opentech/apply/projects/migrations/0001_initial.py b/opentech/apply/projects/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..76e3451ce5468d5f5e00d4b15869b977e286677a --- /dev/null +++ b/opentech/apply/projects/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 2.0.13 on 2019-07-29 07:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('funds', '0064_group_toggle_end_block'), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.TextField()), + ('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='funds.ApplicationSubmission')), + ], + ), + ] diff --git a/opentech/apply/projects/migrations/0002_add_submission_fields_to_project.py b/opentech/apply/projects/migrations/0002_add_submission_fields_to_project.py new file mode 100644 index 0000000000000000000000000000000000000000..a8856252fead9be837a0b4f661313034b77ddfd1 --- /dev/null +++ b/opentech/apply/projects/migrations/0002_add_submission_fields_to_project.py @@ -0,0 +1,33 @@ +# Generated by Django 2.0.13 on 2019-07-30 10:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='contact_address', + field=models.TextField(default=''), + ), + migrations.AddField( + model_name='project', + name='contact_email', + field=models.TextField(default=''), + ), + migrations.AddField( + model_name='project', + name='contact_legal_name', + field=models.TextField(default=''), + ), + migrations.AddField( + model_name='project', + name='value', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + ] diff --git a/opentech/apply/projects/migrations/0003_add_project_lead.py b/opentech/apply/projects/migrations/0003_add_project_lead.py new file mode 100644 index 0000000000000000000000000000000000000000..2699ce4b680cf93c13f080948e29f14370cc4976 --- /dev/null +++ b/opentech/apply/projects/migrations/0003_add_project_lead.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0.13 on 2019-07-31 13:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0002_add_submission_fields_to_project'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='lead', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/opentech/apply/projects/migrations/0004_project_rename_name_to_title.py b/opentech/apply/projects/migrations/0004_project_rename_name_to_title.py new file mode 100644 index 0000000000000000000000000000000000000000..2e735813cb68516d2da0ae8e9102ef2c86b3510a --- /dev/null +++ b/opentech/apply/projects/migrations/0004_project_rename_name_to_title.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-07-11 03:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0003_add_project_lead'), + ] + + operations = [ + migrations.RenameField( + model_name='project', + old_name='name', + new_name='title', + ), + ] diff --git a/opentech/apply/projects/migrations/0005_add_user_to_project.py b/opentech/apply/projects/migrations/0005_add_user_to_project.py new file mode 100644 index 0000000000000000000000000000000000000000..b403e5f14d4de9a0e5e458146b4b7e4443277514 --- /dev/null +++ b/opentech/apply/projects/migrations/0005_add_user_to_project.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.13 on 2019-07-11 03:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0004_project_rename_name_to_title'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_projects', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='project', + name='lead', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lead_projects', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/opentech/apply/projects/migrations/0006_add_project_paf_fields.py b/opentech/apply/projects/migrations/0006_add_project_paf_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..fd61f3cb20dd0684d853f464fa53cef87f931ea1 --- /dev/null +++ b/opentech/apply/projects/migrations/0006_add_project_paf_fields.py @@ -0,0 +1,48 @@ +# Generated by Django 2.0.13 on 2019-08-02 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0005_add_user_to_project'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='contact_phone', + field=models.TextField(default='', verbose_name='Phone'), + ), + migrations.AddField( + model_name='project', + name='proposed_end', + field=models.DateTimeField(null=True, verbose_name='Proposed Start Date'), + ), + migrations.AddField( + model_name='project', + name='proposed_start', + field=models.DateTimeField(null=True, verbose_name='Proposed Start Date'), + ), + migrations.AddField( + model_name='project', + name='user_has_updated_details', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='project', + name='contact_address', + field=models.TextField(default='', verbose_name='Address'), + ), + migrations.AlterField( + model_name='project', + name='contact_email', + field=models.TextField(default='', verbose_name='Email'), + ), + migrations.AlterField( + model_name='project', + name='contact_legal_name', + field=models.TextField(default='', verbose_name='Person or Organisation name'), + ), + ] diff --git a/opentech/apply/projects/migrations/0007_fix_proposed_end_date_verbose_name.py b/opentech/apply/projects/migrations/0007_fix_proposed_end_date_verbose_name.py new file mode 100644 index 0000000000000000000000000000000000000000..3dae6c5a1ac2fcfac3331d7d4f1bfc07d66a0f13 --- /dev/null +++ b/opentech/apply/projects/migrations/0007_fix_proposed_end_date_verbose_name.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-07 08:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0006_add_project_paf_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='proposed_end', + field=models.DateTimeField(null=True, verbose_name='Proposed End Date'), + ), + ] diff --git a/opentech/apply/projects/migrations/0008_add_value_validator.py b/opentech/apply/projects/migrations/0008_add_value_validator.py new file mode 100644 index 0000000000000000000000000000000000000000..27c2506895ffb54a4d911663b73339f3d56dbffc --- /dev/null +++ b/opentech/apply/projects/migrations/0008_add_value_validator.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.13 on 2019-08-07 08:38 + +from decimal import Decimal +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0007_fix_proposed_end_date_verbose_name'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='value', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))]), + ), + ] diff --git a/opentech/apply/projects/migrations/0009_add_approval.py b/opentech/apply/projects/migrations/0009_add_approval.py new file mode 100644 index 0000000000000000000000000000000000000000..92b0c41572e2d1549f53767d3da3dcff0b4424ae --- /dev/null +++ b/opentech/apply/projects/migrations/0009_add_approval.py @@ -0,0 +1,50 @@ +# Generated by Django 2.0.13 on 2019-08-06 09:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0009_documentcategory'), + ] + + operations = [ + migrations.CreateModel( + name='Approval', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='project', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='project', + name='is_locked', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='project', + name='status', + field=models.TextField(choices=[('committed', 'Committed'), ('contracting', 'Contracting'), ('in_progress', 'In Progress'), ('closing', 'Closing'), ('complete', 'Complete')], default='committed'), + ), + migrations.AddField( + model_name='approval', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application_projects.Project'), + ), + migrations.AlterUniqueTogether( + name='approval', + unique_together={('project', 'by')}, + ), + ] diff --git a/opentech/apply/projects/migrations/0009_documentcategory.py b/opentech/apply/projects/migrations/0009_documentcategory.py new file mode 100644 index 0000000000000000000000000000000000000000..6925bcfa68c739c81951cf8a1bc8a21111caeb44 --- /dev/null +++ b/opentech/apply/projects/migrations/0009_documentcategory.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.13 on 2019-08-06 22:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0008_add_value_validator'), + ] + + operations = [ + migrations.CreateModel( + name='DocumentCategory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=254)), + ('recommended_minimum', models.PositiveIntegerField()), + ], + options={ + 'ordering': ('name',), + 'verbose_name_plural': 'Document Categories', + }, + ), + ] diff --git a/opentech/apply/projects/migrations/0010_add_related_names_to_approval_fks.py b/opentech/apply/projects/migrations/0010_add_related_names_to_approval_fks.py new file mode 100644 index 0000000000000000000000000000000000000000..8bae011649d8504a7e77c3cb51548dc151b02b13 --- /dev/null +++ b/opentech/apply/projects/migrations/0010_add_related_names_to_approval_fks.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.13 on 2019-08-07 11:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0009_add_approval'), + ] + + operations = [ + migrations.AlterField( + model_name='approval', + name='by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='approval', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='application_projects.Project'), + ), + ] diff --git a/opentech/apply/projects/migrations/0011_add_packet_file.py b/opentech/apply/projects/migrations/0011_add_packet_file.py new file mode 100644 index 0000000000000000000000000000000000000000..bcf80d0576d0dbf8b2d00d10667c3c03f0e3ba8b --- /dev/null +++ b/opentech/apply/projects/migrations/0011_add_packet_file.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.13 on 2019-08-07 15:50 + +from django.db import migrations, models +import django.db.models.deletion +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0010_add_related_names_to_approval_fks'), + ] + + operations = [ + migrations.CreateModel( + name='PacketFile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('document', models.FileField(upload_to=opentech.apply.projects.models.document_path)), + ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='packet_files', to='application_projects.DocumentCategory')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packet_files', to='application_projects.Project')), + ], + ), + ] 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/migrations/__init__.py b/opentech/apply/projects/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py new file mode 100644 index 0000000000000000000000000000000000000000..4b3f1b98f6011ec827ed8e4632f7495a43134df7 --- /dev/null +++ b/opentech/apply/projects/models.py @@ -0,0 +1,218 @@ +import collections +import decimal +import json +import logging + +from addressfield.fields import ADDRESS_FIELDS_ORDER +from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +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__) + + +class Approval(models.Model): + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals") + by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="approvals") + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['project', 'by'] + + def __str__(self): + return f'Approval of "{self.project.title}" by {self.by}' + + +def document_path(instance, filename): + return f'projects/{instance.project_id}/supporting_documents/{filename}' + + +class PacketFile(models.Model): + category = models.ForeignKey("DocumentCategory", null=True, on_delete=models.CASCADE, related_name="packet_files") + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="packet_files") + + title = models.TextField() + document = models.FileField(upload_to=document_path, storage=PrivateStorage()) + + def __str__(self): + return f'Project file: {self.title}' + + def get_remove_form(self): + """ + Get an instantiated RemoveDocumentForm with this class as `instance`. + + This allows us to build instances of the RemoveDocumentForm for each + instance of PacketFile in the supporting documents template. The + standard Delegated View flow makes it difficult to create these forms + in the view or template. + """ + from .forms import RemoveDocumentForm + return RemoveDocumentForm(instance=self) + + +COMMITTED = 'committed' +CONTRACTING = 'contracting' +PROJECT_STATUS_CHOICES = [ + (COMMITTED, 'Committed'), + (CONTRACTING, 'Contracting'), + ('in_progress', 'In Progress'), + ('closing', 'Closing'), + ('complete', 'Complete'), +] + + +class Project(models.Model): + lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects') + submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='owned_projects') + + title = models.TextField() + + contact_legal_name = models.TextField(_('Person or Organisation name'), default='') + contact_email = models.TextField(_('Email'), default='') + contact_address = models.TextField(_('Address'), default='') + contact_phone = models.TextField(_('Phone'), default='') + value = models.DecimalField( + default=0, + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(decimal.Decimal('0.01'))], + ) + proposed_start = models.DateTimeField(_('Proposed Start Date'), null=True) + proposed_end = models.DateTimeField(_('Proposed End Date'), null=True) + + status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED) + + # tracks read/write state of the Project + is_locked = models.BooleanField(default=False) + + # tracks updates to the Projects fields via the Project Application Form. + user_has_updated_details = models.BooleanField(default=False) + + activities = GenericRelation( + 'activity.Activity', + content_type_field='source_content_type', + object_id_field='source_object_id', + related_query_name='project', + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.title + + def get_address_display(self): + address = json.loads(self.contact_address) + return ', '.join( + address.get(field) + for field in ADDRESS_FIELDS_ORDER + if address.get(field) + ) + + @classmethod + def create_from_submission(cls, submission): + """ + Create a Project from the given submission. + + Returns a new Project or the given ApplicationSubmissions existing + Project. + """ + if not settings.PROJECTS_ENABLED: + logging.error(f'Tried to create a Project for Submission ID={submission.id} while projects are disabled') + return None + + # OneToOne relations on the targetted model cannot be accessed without + # an exception when the relation doesn't exist (is None). Since we + # want to fail fast here, we can use hasattr instead. + if hasattr(submission, 'project'): + return submission.project + + return Project.objects.create( + submission=submission, + title=submission.title, + user=submission.user, + contact_email=submission.user.email, + contact_legal_name=submission.user.full_name, + contact_address=submission.form_data.get('address', ''), + value=submission.form_data.get('value', 0), + ) + + def clean(self): + if self.proposed_start is None: + return + + if self.proposed_end is None: + return + + if self.proposed_start > self.proposed_end: + raise ValidationError(_('Proposed End Date must be after Proposed Start Date')) + + def editable_by(self, user): + if self.editable: + return True + + # Approver can edit it when they are approving + return user.is_approver and self.can_make_approval + + @property + def editable(self): + # Someone must lead the project to make changes + return self.lead and not self.is_locked + + def get_absolute_url(self): + if settings.PROJECTS_ENABLED: + return reverse('apply:projects:detail', args=[self.id]) + return '#' + + @property + def can_make_approval(self): + return self.is_locked and self.status == COMMITTED + + @property + def can_send_for_approval(self): + """ + Wrapper to expose the pending approval state + + We don't want to expose a "Sent for Approval" state to the end User so + we infer it from the current status being "Comitted" and the Project + being locked. + """ + correct_state = self.status == COMMITTED and not self.is_locked + return correct_state and self.user_has_updated_details + + def get_missing_document_categories(self): + """ + Get the number of documents required to meet each DocumentCategorys minimum + """ + # Count the number of documents in each category currently + existing_categories = DocumentCategory.objects.filter(packet_files__project=self) + counter = collections.Counter(existing_categories) + + # Find the difference between the current count and recommended count + for category in DocumentCategory.objects.all(): + current_count = counter[category] + difference = category.recommended_minimum - current_count + if difference > 0: + yield { + 'category': category, + 'difference': difference, + } + + +class DocumentCategory(models.Model): + name = models.CharField(max_length=254) + recommended_minimum = models.PositiveIntegerField() + + def __str__(self): + return self.name + + class Meta: + ordering = ('name',) + verbose_name_plural = 'Document Categories' diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html new file mode 100644 index 0000000000000000000000000000000000000000..8e3e1acc21c727fc912a6082bbc4aa8143343eba --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -0,0 +1,115 @@ +{% load approval_tools %} +{% user_can_edit_project object request.user as editable %} + +<div class="docs-block wrapper--outer-space-large"> + <div class="docs-block__header"> + <h4 class="docs-block__heading">Supporting documents</h4> + </div> + <ul class="docs-block__inner"> + + <li class="docs-block__row"> + <div class="docs-block__row-inner"> + <svg class="icon docs-block__icon is-complete"><use xlink:href="#tick"></use></svg> + <p class="docs-block__title">Proposal</p> + </div> + <div class="docs-block__row-inner"> + <a class="docs-block__link" href="#">View</a> + <a class="docs-block__link" href="#">Download</a> + </div> + </li> + + <li class="docs-block__row"> + <div class="docs-block__row-inner"> + <svg class="icon docs-block__icon{% if object.user_has_updated_details %} is-complete{% endif %}"> + <use xlink:href="#tick"></use> + </svg> + <p class="docs-block__title">Approval Form</p> + </div> + <div class="docs-block__row-inner"> + {% if editable %} + <a class="docs-block__link" href="{% url 'apply:projects:edit' pk=object.pk %}"> + {% if object.user_has_updated_details %} + Edit + {% else %} + Create + {% endif %} + </a> + {% endif %} + {% if object.user_has_updated_details %} + <a class="docs-block__link" href="#"> + View + </a> + {% endif %} + </div> + </li> + + <li class="docs-block__row"> + <div class="docs-block__row-inner"> + <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg> + <p class="docs-block__title">Supporting documents</p> + </div> + {% if editable %} + <div class="docs-block__row-inner"> + <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a> + </div> + {% endif %} + {% if remaining_document_categories %} + <div class="docs-block__info-text"> + <p> + Every project should include the following documents: + </p> + <ul> + {% for missing in remaining_document_categories %} + <li>{{ missing.category.name }} ({{ missing.difference }})</li> + {% endfor %} + </ul> + </div> + {% endif %} + + {% if object.packet_files.exists %} + <ul class="docs-block__document-list"> + {% for document in object.packet_files.all %} + <li class="docs-block__document"> + <div class="docs-block__document-inner"> + <p class="docs-block__document-info"><b>{{ document.title }}</b></p> + <p class="docs-block__document-info">{{ document.category.name }}</p> + </div> + <div class="docs-block__document-inner"> + <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 }} + <input + class="button button--primary button--top-space" + id="{{ remove_document_form.name }}-submit" + name="{{ form_prefix }}{{ remove_document_form.name }}" + type="submit" + form="{{ remove_document_form.name }}" + value="Remove" /> + </form> + </input> + </div> + </li> + {% endfor %} + </ul> + {% endif %} + + </li> + </ul> + <div class="docs-block__buttons"> + {% if object.can_send_for_approval %} + <a data-fancybox + data-src="#send-for-approval" + class="button button--primary" + href="#"> + Submit for Approval + </a> + {% endif %} + <!-- <button class="button button--primary" href="#">Ready for contracting</button> --> + </div> +</div> + +<div class="modal" id="upload-supporting-doc"> + <h4 class="modal__header-bar">Upload a new document</h4> + {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%} +</div> diff --git a/opentech/apply/projects/templates/application_projects/project_admin_detail.html b/opentech/apply/projects/templates/application_projects/project_admin_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..239d18fb9c923900e6e6dac011a4193b54be5821 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_admin_detail.html @@ -0,0 +1,120 @@ +{% extends "application_projects/project_detail.html" %} + +{% load approval_tools %} +{% load static %} + +{% block admin_sidebar %} +<div class="modal" id="send-for-approval"> + <h4 class="modal__header-bar">Request Approval</h4> + + {% if remaining_document_categories %} + <h5>Are you sure you're ready to submit?</h5> + + <p>This project is missing the following documents:</p> + + <ul> + {% for missing in remaining_document_categories %} + <li><strong>{{ missing.category.name }} ({{ missing.difference }})</strong></li> + {% endfor %} + </ul> + {% include 'funds/includes/delegated_form_base.html' with form=request_approval_form value='Submit anyway' cancel=True invert=True %} + {% else %} + {% include 'funds/includes/delegated_form_base.html' with form=request_approval_form value='Request' %} + {% endif %} +</div> + +<div class="modal" id="assign-lead"> + <h4 class="modal__header-bar">Assign Lead</h4> + {% include 'funds/includes/delegated_form_base.html' with form=lead_form value='Update'%} +</div> + +<div class="modal" id="approve"> + <h4 class="modal__header-bar">Add Approval</h4> + {% include 'funds/includes/delegated_form_base.html' with form=add_approval_form value='Approve'%} +</div> + +<div class="modal" id="request-project-changes"> + <h4 class="modal__header-bar">Request Changes</h4> + {% include 'funds/includes/delegated_form_base.html' with form=rejection_form value='Request Changes'%} +</div> + +{% if mobile %} + <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a> +{% endif %} + +<div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}"> + + <h5>Actions to take</h5> + + {% if object.can_send_for_approval %} + <a data-fancybox + data-src="#send-for-approval" + class="button button--bottom-space button--primary button--full-width" + href="#"> + Submit for Approval + </a> + {% endif %} + + {% if object.can_make_approval %} + {% user_can_approve_project object request.user as user_can_approve %} + <a data-fancybox + data-src="#approve" + class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" + href="#"> + Approve + </a> + + <a data-fancybox + data-src="#request-project-changes" + class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}" + href="#"> + Request changes + </a> + {% endif %} + + <!-- <a data-fancybox --> + <!-- data-src="#ready-for-contracting" --> + <!-- class="button button--primary button--full-width" --> + <!-- href="#"> --> + <!-- Ready for contracting --> + <!-- </a> --> + + + <p class="sidebar__separator">Assign</p> + + <a data-fancybox + data-src="#assign-lead" + class="button button--bottom-space button--white button--full-width" + href="#"> + Lead + </a> + + <!-- <a data-fancybox --> + <!-- data-src="#update-meta-categories" --> + <!-- class="button button--bottom-space button--white button--full-width" --> + <!-- href="#"> --> + <!-- Meta Categories --> + <!-- </a> --> + +</div> + +{% if approvals %} +<div class="sidebar__inner"> + <h5>Approved By:</h5> + + {% for approval in approvals %} + <p>{{ approval.by }} - {{ approval.created_at|date:"Y-m-d" }}</p> + {% endfor %} +</div> +{% endif %} + +{% endblock %} + +{% block extra_css %} + <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + +{% block extra_js %} + {{ block.super }} + <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..2ca4b45978990b5f19dc4d1b5537fd11141a2aa4 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html @@ -0,0 +1,18 @@ +{% extends "application_projects/project_detail.html" %} + +{% block notifications %} +{% if not object.editable %} +<div class="wrapper wrapper--sidebar"> + <div class="wrapper--sidebar--inner wrapper--error"> + <div> + <p>Your project is not editable at this point.</p> + {% if not object.lead %} + <p>We are awaiting a lead to be assigned.</p> + {% else %} + <p>It is currently under review by a staff member.</p> + {% endif %} + </div> + </div> +</div> +{% endif %} +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..73ef24b09b9a8ede66a101f35258632a2ba07b28 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -0,0 +1,157 @@ +{% extends "base-apply.html" %} + +{% load static %} +{% load wagtailcore_tags %} + +{% block title %}{{ object.title }}{% endblock %} + +{% block body_class %}{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <h1 class="beta heading heading--no-margin heading--bold">{{ object.title }}</h1> + <h5 class="heading heading--meta"> + + <span> + {% if public_page %} + <a class="link--transparent link--underlined" href="{% pageurl public_page %}" > + {{ object.submission.page }} + </a> + {% else %} + {{ object.submission.page }} + {% endif %} + </span> + + {% if request.user.is_apply_staff and object.submission.round %} + <span> + <a class="link--transparent link--underlined" href="{% url 'apply:rounds:detail' pk=object.submission.round.pk %}">{{ object.submission.round }}</a> + </span> + {% else %} + <span>{{ object.submission.round }}</span> + {% endif %} + + <span>Lead: {{ object.lead }}</span> + + </h5> + {# {% status_bar object.workflow object.phase request.user same_stage=True%} #} + + <div class="tabs js-tabs"> + <div class="tabs__container"> + <a class="tab__item" href="#details" data-tab="tab-1"> + Details + </a> + + <a class="tab__item" href="#communications" data-tab="tab-2"> + Communications + </a> + + <a class="tab__item" href="#activity-feed" data-tab="tab-3"> + Activity Feed + </a> + </div> + </div> + </div> +</div> + +<div class="wrapper wrapper--large wrapper--tabs js-tabs-content"> + <div class="tabs__content" id="tab-1"> + {% block notifications %} + {% endblock %} + <div class="wrapper wrapper--sidebar"> + <article class="wrapper--sidebar--inner"> + <h3>Project Information</h3> + <div class="grid grid--proposal-info"> + <div> + <h5>Proposed start date</h5> + <p>{{ object.proposed_start|date:"j F Y"|default:"-" }}</p> + </div> + + <div> + <h5>Project Proposed end date</h5> + <p>{{ object.proposed_end|date:"j F Y"|default:"-" }}</p> + </div> + + <div> + <h5>Legal name</h5> + <p>{{ object.contact_legal_name|default:"-" }}</p> + </div> + + <div> + <h5>Email</h5> + {% if object.contact_email %} + <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a> + {% else %} + - + {% endif %} + </div> + </div> + + <a class="link--reveal-proposal js-toggle-propsoal-info" href="#">Show more</a> + + <div class="rich-text--hidden js-rich-text-hidden"> + <div> + <h5>Address</h5> + <p>{{ object.get_address_display|default:"-"}}</p> + </div> + + <div> + <h5>Phone</h5> + <p>{{ object.phone|default:"-" }}</p> + </div> + + <div> + <h5>Value</h5> + <p>${{ object.value|default:"-" }}</p> + </div> + </div> + + {# <div class="wrapper wrapper--outer-space-large"> #} + {# {% include "funds/includes/funding_block.html" %} #} + {# {% include "funds/includes/payment_requests.html" %} #} + {# {% include "funds/includes/invoice_block.html" %} #} + {# </div> #} + + {% include "application_projects/includes/supporting_documents.html" %} + </article> + + <aside class="sidebar"> + {% if request.user.is_apply_staff %} + {% block admin_sidebar %}{% endblock %} + {% endif %} + + <div class="sidebar__inner"> + <h5>Meta Categories</h5> + + <p>Meta Category</p> + <p>Meta Category</p> + <p>Meta Category</p> + </div> + + </aside> + </div> + </div> + + {# Tab 2 #} + <div class="tabs__content" id="tab-2"> + <div class="feed"> + {% include "activity/include/comment_form.html" %} + {% include "activity/include/comment_list.html" with editable=True %} + </div> + </div> + + {# Tab 3 #} + <div class="tabs__content" id="tab-3"> + <div class="feed"> + {% include "activity/include/action_list.html" %} + </div> + </div> +</div> +{% endblock content %} + +{% block extra_js %} + <script src="{% static 'js/apply/tabs.js' %}"></script> + <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script> + <script src="{% static 'js/apply/file-uploads.js' %}"></script> + <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_form.html b/opentech/apply/projects/templates/application_projects/project_form.html new file mode 100644 index 0000000000000000000000000000000000000000..6834a47d5e56a0cc822ea79be4d4ea0eea28ee70 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_form.html @@ -0,0 +1,40 @@ +{% extends "base-apply.html" %} + +{% load static %} + +{% block title %}Editing: {{ object.title }}{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <h2 class="heading heading--no-margin">Editing: {{ object.title}}</h2> + </div> +</div> + +{% include "forms/includes/form_errors.html" with form=form %} + +<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar"> + <div class="wrapper--sidebar--inner"> + <form class="form" method="post"> + {% csrf_token %} + + {% for field in form %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + + <a class="button button--submit button--top-space button--white" href="{{ object.get_absolute_url }}"> + Cancel + </a> + + <button class="button button--submit button--top-space button--primary" type="submit"> + Save + </button> + + </form> + </div> +</div> +{% endblock %} diff --git a/opentech/apply/projects/templatetags/__init__.py b/opentech/apply/projects/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/projects/templatetags/approval_tools.py b/opentech/apply/projects/templatetags/approval_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..2c51d1a59462bedbe371bdc18f82c75a6598196a --- /dev/null +++ b/opentech/apply/projects/templatetags/approval_tools.py @@ -0,0 +1,18 @@ +from django import template + +register = template.Library() + + +def user_has_approved(project, user): + """Has the given User already approved the given Project""" + return project.approvals.filter(by=user).exists() + + +@register.simple_tag +def user_can_approve_project(project, user): + return user.is_approver and not user_has_approved(project, user) + + +@register.simple_tag +def user_can_edit_project(project, user): + return project.editable_by(user) diff --git a/opentech/apply/projects/tests/__init__.py b/opentech/apply/projects/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..b91c0aadf1e1c82f41884838451e7852c18018be --- /dev/null +++ b/opentech/apply/projects/tests/factories.py @@ -0,0 +1,78 @@ +import decimal +import json + +import factory +from django.utils import timezone + +from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory +from opentech.apply.projects.models import ( + DocumentCategory, + PacketFile, + Project, +) +from opentech.apply.users.tests.factories import StaffFactory, UserFactory + + +ADDRESS = { + 'country': 'GB', + 'thoroughfare': factory.Faker('street_name').generate({}), + 'premise': factory.Faker('building_number').generate({}), + 'locality': { + 'localityname': factory.Faker('city').generate({}), + 'administrativearea': factory.Faker('city').generate({}), + 'postal_code': 'SW1 4AQ', + } +} + + +def address_to_form_data(): + """ + Generate a AddressField compatible dictionary from the address data + """ + return { + 'contact_address_0': ADDRESS['country'], + 'contact_address_1': ADDRESS['thoroughfare'], + 'contact_address_2': ADDRESS['premise'], + 'contact_address_3_0': ADDRESS['locality']['localityname'], + 'contact_address_3_1': ADDRESS['locality']['administrativearea'], + 'contact_address_3_2': ADDRESS['locality']['postal_code'], + } + + +class DocumentCategoryFactory(factory.DjangoModelFactory): + name = factory.Sequence('name {}'.format) + recommended_minimum = 1 + + class Meta: + model = DocumentCategory + + +class ProjectFactory(factory.DjangoModelFactory): + submission = factory.SubFactory(ApplicationSubmissionFactory) + user = factory.SubFactory(UserFactory) + + title = factory.Sequence('name {}'.format) + lead = factory.SubFactory(StaffFactory) + contact_legal_name = 'test' + contact_email = 'test@example.com' + contact_address = json.dumps(ADDRESS) + contact_phone = '555 1234' + value = decimal.Decimal('100') + proposed_start = factory.LazyFunction(timezone.now) + proposed_end = factory.LazyFunction(timezone.now) + + is_locked = False + + class Meta: + model = Project + + +class PacketFileFactory(factory.DjangoModelFactory): + category = factory.SubFactory(DocumentCategoryFactory) + project = factory.SubFactory(ProjectFactory) + + title = factory.Sequence('name {}'.format) + document = factory.django.FileField() + + class Meta: + model = PacketFile diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..5c0d157d5b81315841a9134cb98551d30d50d1b7 --- /dev/null +++ b/opentech/apply/projects/tests/test_forms.py @@ -0,0 +1,26 @@ +from django.test import TestCase + +from ..forms import ProjectApprovalForm +from .factories import ProjectFactory, address_to_form_data + + +class TestProjectApprovalForm(TestCase): + def test_updating_fields_sets_changed_flag(self): + project = ProjectFactory() + + self.assertFalse(project.user_has_updated_details) + + data = { + 'title': f'{project.title} test', + 'contact_legal_name': project.contact_legal_name, + 'contact_email': project.contact_email, + 'contact_phone': project.contact_phone, + 'value': project.value, + 'proposed_start': project.proposed_start, + 'proposed_end': project.proposed_end, + } + data.update(address_to_form_data()) + form = ProjectApprovalForm(instance=project, data=data) + form.save() + + self.assertTrue(project.user_has_updated_details) diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..ded83fe644999416caf85afe0ef196c180b707fd --- /dev/null +++ b/opentech/apply/projects/tests/test_models.py @@ -0,0 +1,61 @@ +from django.test import TestCase + +from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory + +from ..models import Project +from .factories import (DocumentCategoryFactory, PacketFileFactory, + ProjectFactory) + + +class TestProjectModel(TestCase): + def test_create_from_submission(self): + submission = ApplicationSubmissionFactory() + + project = Project.create_from_submission(submission) + + self.assertEquals(project.submission, submission) + self.assertEquals(project.title, submission.title) + self.assertEquals(project.user, submission.user) + + def test_get_missing_document_categories_with_enough_documents(self): + project = ProjectFactory() + category = DocumentCategoryFactory(recommended_minimum=1) + PacketFileFactory(project=project, category=category) + + self.assertEqual(project.packet_files.count(), 1) + + missing = list(project.get_missing_document_categories()) + + self.assertEqual(len(missing), 0) + + def test_get_missing_document_categories_with_no_documents(self): + project = ProjectFactory() + category = DocumentCategoryFactory(recommended_minimum=1) + + self.assertEqual(project.packet_files.count(), 0) + + missing = list(project.get_missing_document_categories()) + + self.assertEqual(len(missing), 1) + self.assertEqual(missing[0]['category'], category) + self.assertEqual(missing[0]['difference'], 1) + + def test_get_missing_document_categories_with_some_documents(self): + project = ProjectFactory() + + category1 = DocumentCategoryFactory(recommended_minimum=5) + PacketFileFactory(project=project, category=category1) + PacketFileFactory(project=project, category=category1) + + category2 = DocumentCategoryFactory(recommended_minimum=3) + PacketFileFactory(project=project, category=category2) + + self.assertEqual(project.packet_files.count(), 3) + + missing = list(project.get_missing_document_categories()) + + self.assertEqual(len(missing), 2) + self.assertEqual(missing[0]['category'], category1) + self.assertEqual(missing[0]['difference'], 3) + self.assertEqual(missing[1]['category'], category2) + self.assertEqual(missing[1]['difference'], 2) diff --git a/opentech/apply/projects/tests/test_settings.py b/opentech/apply/projects/tests/test_settings.py new file mode 100644 index 0000000000000000000000000000000000000000..a627795b18231c015c2822138764dcb765fc82ad --- /dev/null +++ b/opentech/apply/projects/tests/test_settings.py @@ -0,0 +1,15 @@ +from django.test import TestCase, override_settings + +from opentech.apply.users.tests.factories import StaffFactory + + +class TestProjectFeatureFlag(TestCase): + @override_settings(PROJECTS_ENABLED=False) + def test_urls_404_when_turned_off(self): + self.client.force_login(StaffFactory()) + + response = self.client.get('/apply/projects/', follow=True) + self.assertEqual(response.status_code, 404) + + response = self.client.get('/apply/projects/1/', follow=True) + self.assertEqual(response.status_code, 404) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..511bcdfb0aaa197ac221becc1aaff112b615a1b7 --- /dev/null +++ b/opentech/apply/projects/tests/test_views.py @@ -0,0 +1,340 @@ +from io import BytesIO + +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse + +from opentech.apply.funds.tests.factories import LabSubmissionFactory +from opentech.apply.users.tests.factories import ( + ApplicantFactory, + ApproverFactory, + ReviewerFactory, + StaffFactory, + SuperUserFactory, + UserFactory, +) +from opentech.apply.utils.testing.tests import BaseViewTestCase + +from ..forms import SetPendingForm +from .factories import ( + DocumentCategoryFactory, + PacketFileFactory, + ProjectFactory, +) + + +class TestCreateApprovalView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_creating_an_approval_happy_path(self): + project = ProjectFactory() + self.assertEqual(project.approvals.count(), 0) + + response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + approval = project.approvals.first() + + self.assertEqual(project.approvals.count(), 1) + self.assertFalse(project.is_locked) + self.assertEqual(project.status, 'contracting') + + self.assertEqual(approval.project_id, project.pk) + + +class BaseProjectDetailTestCase(BaseViewTestCase): + url_name = 'funds:projects:{}' + base_view_name = 'detail' + + def get_kwargs(self, instance): + return {'pk': instance.id} + + +class TestStaffProjectDetailView(BaseProjectDetailTestCase): + user_factory = StaffFactory + + def test_has_access(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.status_code, 200) + + def test_lab_project_renders(self): + project = ProjectFactory(submission=LabSubmissionFactory()) + response = self.get_page(project) + self.assertEqual(response.status_code, 200) + + +class TestUserProjectDetailView(BaseProjectDetailTestCase): + user_factory = UserFactory + + def test_doesnt_have_access(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.status_code, 403) + + def test_owner_has_access(self): + project = ProjectFactory(user=self.user) + response = self.get_page(project) + self.assertEqual(response.status_code, 200) + + +class TestSuperUserProjectDetailView(BaseProjectDetailTestCase): + user_factory = SuperUserFactory + + def test_has_access(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.status_code, 200) + + +class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase): + user_factory = ReviewerFactory + + def test_doesnt_have_access(self): + project = ProjectFactory() + response = self.get_page(project) + self.assertEqual(response.status_code, 403) + + +class TestRemoveDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_remove_document(self): + project = ProjectFactory() + document = PacketFileFactory() + + response = self.post_page(project, { + 'form-submitted-remove_document_form': '', + 'id': document.id, + }) + project.refresh_from_db() + + self.assertEqual(response.status_code, 200) + self.assertNotIn(document.pk, project.packet_files.values_list('pk', flat=True)) + + def test_remove_non_existent_document(self): + response = self.post_page(ProjectFactory(), { + 'form-submitted-remove_document_form': '', + 'id': 1, + }) + self.assertEqual(response.status_code, 200) + + +class TestSendForApprovalView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_send_for_approval_fails_when_project_is_locked(self): + project = ProjectFactory(is_locked=True) + + # The view doesn't have any custom changes when form validation fails + # so check that directly. + form = SetPendingForm(instance=project) + self.assertFalse(form.is_valid()) + + def test_send_for_approval_fails_when_project_is_not_in_committed_state(self): + project = ProjectFactory(status='in_progress') + + # The view doesn't have any custom changes when form validation fails + # so check that directly. + form = SetPendingForm(instance=project) + self.assertFalse(form.is_valid()) + + def test_send_for_approval_happy_path(self): + project = ProjectFactory(is_locked=False, status='committed') + + response = self.post_page(project, {'form-submitted-request_approval_form': ''}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertTrue(project.is_locked) + self.assertEqual(project.status, 'committed') + + +class TestUploadDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_upload_document(self): + category = DocumentCategoryFactory() + project = ProjectFactory() + + test_doc = BytesIO(b'somebinarydata') + test_doc.name = 'document.pdf' + + response = self.post_page(project, { + 'form-submitted-document_form': '', + 'title': 'test document', + 'category': category.id, + 'document': test_doc, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.packet_files.count(), 1) + + +class BaseProjectEditTestCase(BaseViewTestCase): + url_name = 'funds:projects:{}' + base_view_name = 'edit' + + def get_kwargs(self, instance): + return {'pk': instance.id} + + +class TestUserProjectEditView(BaseProjectEditTestCase): + user_factory = UserFactory + + def test_does_not_have_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 403) + + def test_owner_has_access(self): + project = ProjectFactory(user=self.user) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_no_lead_redirects(self): + project = ProjectFactory(user=self.user, lead=None) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + def test_locked_redirects(self): + project = ProjectFactory(user=self.user, is_locked=True) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + +class TestStaffProjectEditView(BaseProjectEditTestCase): + user_factory = StaffFactory + + def test_staff_user_has_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_no_lead_redirects(self): + project = ProjectFactory(user=self.user, lead=None) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + def test_locked_redirects(self): + project = ProjectFactory(user=self.user, is_locked=True) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + +class TestApproverProjectEditView(BaseProjectEditTestCase): + user_factory = ApproverFactory + + def test_approver_has_access_locked(self): + project = ProjectFactory(is_locked=True) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + +class TestSuperProjectEditView(BaseProjectEditTestCase): + user_factory = StaffFactory + + def test_has_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + +class TestReviewerProjectEditView(BaseProjectEditTestCase): + user_factory = ReviewerFactory + + def test_does_not_have_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.redirect_chain, []) + + +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 new file mode 100644 index 0000000000000000000000000000000000000000..f3ec5b4485357925701ee72aa5084d631c8ceda4 --- /dev/null +++ b/opentech/apply/projects/urls.py @@ -0,0 +1,17 @@ +from django.conf import settings +from django.urls import include, path + +from .views import ProjectDetailView, ProjectEditView, ProjectPrivateMediaView + +app_name = 'projects' + +urlpatterns = [] + +if settings.PROJECTS_ENABLED: + urlpatterns = [ + path('<int:pk>/', include([ + path('', ProjectDetailView.as_view(), name='detail'), + path('edit/', ProjectEditView.as_view(), name="edit"), + path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"), + ])), + ] diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py new file mode 100644 index 0000000000000000000000000000000000000000..812fee430e921d6fcb3e7ad812bad6a531000799 --- /dev/null +++ b/opentech/apply/projects/views.py @@ -0,0 +1,261 @@ +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 _ +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) + +from .forms import ( + CreateApprovalForm, + ProjectApprovalForm, + ProjectEditForm, + RejectionForm, + RemoveDocumentForm, + SetPendingForm, + UpdateProjectLeadForm, + UploadDocumentForm, +) +from .models import CONTRACTING, Approval, Project, PacketFile + + +@method_decorator(staff_required, name='dispatch') +class CreateApprovalView(DelegatedViewMixin, CreateView): + context_name = 'add_approval_form' + form_class = CreateApprovalForm + model = Approval + + @transaction.atomic() + def form_valid(self, form): + project = self.kwargs['object'] + form.instance.project = project + response = super().form_valid(form) + + messenger( + MESSAGES.APPROVE_PROJECT, + request=self.request, + user=self.request.user, + source=project, + ) + + project.is_locked = False + project.status = CONTRACTING + project.save(update_fields=['is_locked', 'status']) + + return response + + +@method_decorator(staff_required, name='dispatch') +class RejectionView(DelegatedViewMixin, UpdateView): + context_name = 'rejection_form' + form_class = RejectionForm + model = Project + + def form_valid(self, form): + messenger( + MESSAGES.REQUEST_PROJECT_CHANGE, + request=self.request, + user=self.request.user, + source=self.object, + comment=form.cleaned_data['comment'], + ) + + self.object.is_locked = False + self.object.save(update_fields=['is_locked']) + + return redirect(self.object) + + +@method_decorator(staff_required, name='dispatch') +class RemoveDocumentView(DelegatedViewMixin, FormView): + context_name = 'remove_document_form' + form_class = RemoveDocumentForm + model = Project + + def form_valid(self, form): + document_id = form.cleaned_data["id"] + project = self.kwargs['object'] + + try: + project.packet_files.get(pk=document_id).delete() + except PacketFile.DoesNotExist: + pass + + return redirect(project) + + +@method_decorator(staff_required, name='dispatch') +class SendForApprovalView(DelegatedViewMixin, UpdateView): + context_name = 'request_approval_form' + form_class = SetPendingForm + model = Project + + def form_valid(self, form): + # lock project + response = super().form_valid(form) + + messenger( + MESSAGES.SEND_FOR_APPROVAL, + request=self.request, + user=self.request.user, + source=self.object, + ) + + return response + + +@method_decorator(staff_required, name='dispatch') +class UpdateLeadView(DelegatedViewMixin, UpdateView): + model = Project + form_class = UpdateProjectLeadForm + context_name = 'lead_form' + + def form_valid(self, form): + # Fetch the old lead from the database + old = copy(self.get_object()) + + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_PROJECT_LEAD, + request=self.request, + user=self.request.user, + source=form.instance, + related=old.lead or 'Unassigned', + ) + + return response + + +@method_decorator(staff_required, name='dispatch') +class UploadDocumentView(DelegatedViewMixin, CreateView): + context_name = 'document_form' + form_class = UploadDocumentForm + model = Project + + def form_valid(self, form): + project = self.kwargs['object'] + form.instance.project = project + response = super().form_valid(form) + + messenger( + MESSAGES.UPLOAD_DOCUMENT, + request=self.request, + user=self.request.user, + source=project, + title=form.instance.title + ) + + return response + + +class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): + form_views = [ + CommentFormView, + CreateApprovalView, + RejectionView, + RemoveDocumentView, + SendForApprovalView, + UpdateLeadView, + UploadDocumentView, + ] + model = Project + template_name_suffix = '_admin_detail' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['approvals'] = self.object.approvals.distinct('by') + context['remaining_document_categories'] = list(self.object.get_missing_document_categories()) + return context + + +class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): + form_views = [ + CommentFormView, + ] + + model = Project + template_name_suffix = '_applicant_detail' + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + # This view is only for applicants. + if project.user != request.user: + raise PermissionDenied + 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 + + +class ProjectApprovalEditView(UpdateView): + form_class = ProjectApprovalForm + model = Project + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + if not project.editable_by(request.user): + messages.info(self.request, _('You are not allowed to edit the project at this time')) + return redirect(project) + return super().dispatch(request, *args, **kwargs) + + +class ApplicantProjectEditView(UpdateView): + form_class = ProjectEditForm + model = Project + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + # This view is only for applicants. + if project.user != request.user: + raise PermissionDenied + + if not project.editable_by(request.user): + messages.info(self.request, _('You are not allowed to edit the project at this time')) + return redirect(project) + + return super().dispatch(request, *args, **kwargs) + + +class ProjectEditView(ViewDispatcher): + admin_view = ProjectApprovalEditView + applicant_view = ApplicantProjectEditView diff --git a/opentech/apply/projects/wagtail_hooks.py b/opentech/apply/projects/wagtail_hooks.py new file mode 100644 index 0000000000000000000000000000000000000000..b33974ab8c280ff6e53c4268739dda3350746c3b --- /dev/null +++ b/opentech/apply/projects/wagtail_hooks.py @@ -0,0 +1,6 @@ +from wagtail.contrib.modeladmin.options import modeladmin_register + +from .admin import ManageAdminGoup + + +modeladmin_register(ManageAdminGoup) diff --git a/opentech/apply/review/views.py b/opentech/apply/review/views.py index 15fe39ba7e28dad31b7826e452353713061ac48f..ffadaa5567327c7a60e7a6296646aec9da9da4e5 100644 --- a/opentech/apply/review/views.py +++ b/opentech/apply/review/views.py @@ -92,7 +92,7 @@ class ReviewEditView(UserPassesTestMixin, BaseStreamForm, UpdateView): MESSAGES.EDIT_REVIEW, user=self.request.user, request=self.request, - submission=review.submission, + source=review.submission, related=review, ) response = super().form_valid(form) @@ -159,7 +159,7 @@ class ReviewCreateOrUpdateView(BaseStreamForm, CreateOrUpdateView): MESSAGES.NEW_REVIEW, request=self.request, user=self.request.user, - submission=self.submission, + source=self.submission, related=self.object, ) @@ -299,7 +299,7 @@ class ReviewOpinionFormView(UserPassesTestMixin, CreateView): MESSAGES.REVIEW_OPINION, request=self.request, user=self.request.user, - submission=self.review.submission, + source=self.review.submission, related=opinion, ) @@ -399,7 +399,7 @@ class ReviewDeleteView(UserPassesTestMixin, DeleteView): MESSAGES.DELETE_REVIEW, user=request.user, request=request, - submission=review.submission, + source=review.submission, related=review, ) response = super().delete(request, *args, **kwargs) 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/users/groups.py b/opentech/apply/users/groups.py index cc9cad3806bf3e4bf2dd4a435da1170dcbb10c0a..0fb0a9f0bff52c703b3e7e42506034fca497784d 100644 --- a/opentech/apply/users/groups.py +++ b/opentech/apply/users/groups.py @@ -4,6 +4,7 @@ REVIEWER_GROUP_NAME = 'Reviewer' TEAMADMIN_GROUP_NAME = 'Team Admin' PARTNER_GROUP_NAME = 'Partner' COMMUNITY_REVIEWER_GROUP_NAME = 'Community reviewer' +APPROVER_GROUP_NAME = 'Approver' GROUPS = [ { @@ -30,4 +31,8 @@ GROUPS = [ 'name': COMMUNITY_REVIEWER_GROUP_NAME, 'permissions': [], }, + { + 'name': APPROVER_GROUP_NAME, + 'permissions': [], + } ] diff --git a/opentech/apply/users/migrations/0013_add_approver_group.py b/opentech/apply/users/migrations/0013_add_approver_group.py new file mode 100644 index 0000000000000000000000000000000000000000..54f9f968f7acf0efcbb62232970719a4ac83517f --- /dev/null +++ b/opentech/apply/users/migrations/0013_add_approver_group.py @@ -0,0 +1,43 @@ +# Generated by Django 2.0.13 on 2019-08-05 13:12 + +from django.core.exceptions import ObjectDoesNotExist +from django.core.management.sql import emit_post_migrate_signal +from django.db import migrations + +from opentech.apply.users.groups import APPROVER_GROUP_NAME, GROUPS + + +def add_groups(apps, schema_editor): + # Workaround for https://code.djangoproject.com/ticket/23422 + db_alias = schema_editor.connection.alias + emit_post_migrate_signal(2, False, db_alias) + + Group = apps.get_model('auth.Group') + Permission = apps.get_model('auth.Permission') + + for group_data in GROUPS: + group, created = Group.objects.get_or_create(name=group_data['name']) + for codename in group_data['permissions']: + try: + permission = Permission.objects.get(codename=codename) + except ObjectDoesNotExist: + print(f"Could not find the '{permission}' permission") + continue + + group.permissions.add(permission) + + +def remove_groups(apps, schema_editor): + Group = apps.get_model('auth.Group') + Group.objects.filter(name=APPROVER_GROUP_NAME).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0012_set_applicant_group'), + ] + + operations = [ + migrations.RunPython(add_groups, remove_groups) + ] diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py index 9609eb97e1ebfdf0d7b2b5b866f828088fba333b..768b583c958865dcd2851c39fa5ffeb686d3845b 100644 --- a/opentech/apply/users/models.py +++ b/opentech/apply/users/models.py @@ -1,11 +1,14 @@ -from django.db import models -from django.db.models import Q from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, BaseUserManager, Group +from django.db import models +from django.db.models import Q from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from .groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME, PARTNER_GROUP_NAME, COMMUNITY_REVIEWER_GROUP_NAME + +from .groups import (APPLICANT_GROUP_NAME, APPROVER_GROUP_NAME, + COMMUNITY_REVIEWER_GROUP_NAME, PARTNER_GROUP_NAME, + REVIEWER_GROUP_NAME, STAFF_GROUP_NAME) from .utils import send_activation_email @@ -27,6 +30,9 @@ class UserQuerySet(models.QuerySet): def applicants(self): return self.filter(groups__name=APPLICANT_GROUP_NAME) + def approvers(self): + return self.filter(groups__name=APPROVER_GROUP_NAME) + class UserManager(BaseUserManager.from_queryset(UserQuerySet)): use_in_migrations = True @@ -132,6 +138,10 @@ class User(AbstractUser): def is_applicant(self): return self.groups.filter(name=APPLICANT_GROUP_NAME).exists() + @cached_property + def is_approver(self): + return self.groups.filter(name=APPROVER_GROUP_NAME).exists() + class Meta: ordering = ('full_name', 'email') diff --git a/opentech/apply/users/tests/factories.py b/opentech/apply/users/tests/factories.py index ea11d35ec695ddcbff5333b98b14f357ad616271..0fb5bee111ed2029b74d7fd4b48c2285be66fe88 100644 --- a/opentech/apply/users/tests/factories.py +++ b/opentech/apply/users/tests/factories.py @@ -6,7 +6,12 @@ from django.utils.text import slugify import factory -from ..groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME +from ..groups import ( + APPLICANT_GROUP_NAME, + APPROVER_GROUP_NAME, + REVIEWER_GROUP_NAME, + STAFF_GROUP_NAME, +) class GroupFactory(factory.DjangoModelFactory): @@ -60,6 +65,16 @@ class StaffFactory(OAuthUserFactory): self.groups.add(GroupFactory(name=STAFF_GROUP_NAME)) +class ApproverFactory(StaffFactory): + @factory.post_generation + def groups(self, create, extracted, **kwargs): + if create: + self.groups.add( + GroupFactory(name=STAFF_GROUP_NAME), + GroupFactory(name=APPROVER_GROUP_NAME), + ) + + class SuperUserFactory(StaffFactory): is_superuser = True 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/apply/utils/views.py b/opentech/apply/utils/views.py index 95e1509ee3007ba05058074a28a8c1bbe579d200..6ea0fb1673f27e375ac389147740534bf6a3ed73 100644 --- a/opentech/apply/utils/views.py +++ b/opentech/apply/utils/views.py @@ -96,7 +96,7 @@ class DelegateableView(DelegatableBase): def post(self, request, *args, **kwargs): self.object = self.get_object() - kwargs['submission'] = self.object + kwargs['object'] = self.object return super().post(request, *args, **kwargs) diff --git a/opentech/settings/base.py b/opentech/settings/base.py index 7d2b0b3855d523850e3d37d7e1b63387f27e1a9c..95637252a885211a5508b7b8832bb20762c5af1c 100644 --- a/opentech/settings/base.py +++ b/opentech/settings/base.py @@ -77,6 +77,7 @@ INSTALLED_APPS = [ 'opentech.apply.determinations', 'opentech.apply.stream_forms', 'opentech.apply.utils', + 'opentech.apply.projects.apps.ProjectsConfig', 'opentech.public.funds', 'opentech.public.home', @@ -184,6 +185,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', ], }, }, @@ -626,3 +628,9 @@ REST_FRAMEWORK = { 'rest_framework.permissions.IsAuthenticated', ) } + + +# Projects Feature Flag +PROJECTS_ENABLED = False +if env.get('PROJECTS_ENABLED', 'false').lower().strip() == 'true': + PROJECTS_ENABLED = True 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/static_src/src/javascript/apply/tabs.js b/opentech/static_src/src/javascript/apply/tabs.js index 27e6a900499855ef1f662334ca5dcfd0b4a5dbea..29ebab1030290dec3f8875b97d37d1ef3e3c4639 100644 --- a/opentech/static_src/src/javascript/apply/tabs.js +++ b/opentech/static_src/src/javascript/apply/tabs.js @@ -56,9 +56,10 @@ updateTabOnLoad() { // Find tab with matching hash and activate const url = document.location.toString(); - const match = this.findTab(url.split('#')[1]); + const tabName = url.split('#')[1]; + const match = this.findTab(tabName); - this.addTabClasses(match); + return tabName ? this.addTabClasses(match) : this.addTabClasses(null); } tabs(e) { diff --git a/opentech/static_src/src/javascript/apply/toggle-payment-block.js b/opentech/static_src/src/javascript/apply/toggle-payment-block.js new file mode 100644 index 0000000000000000000000000000000000000000..603c10644aa686a31dcf5fb0003d8cf17a81d2ea --- /dev/null +++ b/opentech/static_src/src/javascript/apply/toggle-payment-block.js @@ -0,0 +1,17 @@ +(function ($) { + + 'use strict'; + + function togglePaymentBlock() { + $('.js-payment-block-rejected-link').click(function (e) { + e.preventDefault(); + + this.innerHTML = (this.innerHTML === 'Show rejected') ? 'Hide rejected' : 'Show rejected'; + + $('.js-payment-block-rejected-table').toggleClass('is-hidden'); + }); + } + + togglePaymentBlock(); + +})(jQuery); diff --git a/opentech/static_src/src/javascript/apply/toggle-proposal-info.js b/opentech/static_src/src/javascript/apply/toggle-proposal-info.js new file mode 100644 index 0000000000000000000000000000000000000000..c877874ff57725497e7f3fc91f4c2559fc78bedb --- /dev/null +++ b/opentech/static_src/src/javascript/apply/toggle-proposal-info.js @@ -0,0 +1,24 @@ +(function ($) { + + 'use strict'; + + function toggleProposalInfo() { + $('.js-toggle-propsoal-info').click(function (e) { + e.preventDefault(); + const activeClass = 'is-open'; + + if (this.innerHTML === 'Show more') { + this.innerHTML = 'Hide'; + } + else { + this.innerHTML = 'Show more'; + } + + $(this).toggleClass(activeClass); + $('.js-rich-text-hidden').toggleClass(activeClass); + }); + } + + toggleProposalInfo(); + +})(jQuery); diff --git a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss index 2e12efb8da5cfd2a96acf318a922a9f07ed25e87..13f5230c23f064fca76588186ded37ac3d002c9b 100644 --- a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss +++ b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss @@ -45,6 +45,12 @@ padding-right: 0; } } + + &.comments { + @include media-query($table-breakpoint) { + width: 110px; + } + } } tr { @@ -158,6 +164,14 @@ } } + &.fund, + &.round, + &.screening_status { // sass-lint:disable-line class-name-format + -webkit-hyphens: auto; // sass-lint:disable-line no-vendor-prefixes + -ms-hyphens: auto; // sass-lint:disable-line no-vendor-prefixes + hyphens: auto; + } + // arrow to toggle project info - added via js @include media-query($table-breakpoint) { .arrow { diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss index 9d7ebc20301a487cd00ae7cd13ea324a02356687..9a1963268d8322077716280ad7bd3c63eb70a028 100644 --- a/opentech/static_src/src/sass/apply/components/_button.scss +++ b/opentech/static_src/src/sass/apply/components/_button.scss @@ -133,7 +133,7 @@ &--half-width { width: 50%; - padding: 10px 0; + padding: 10px; text-align: center; margin-right: 20px; @@ -165,6 +165,7 @@ right: 15px; font-size: 30px; content: '+'; + line-height: 1.2; } &.is-active { diff --git a/opentech/static_src/src/sass/apply/components/_docs-block.scss b/opentech/static_src/src/sass/apply/components/_docs-block.scss new file mode 100644 index 0000000000000000000000000000000000000000..a2d695259d9c84954a6cd946a29054067539dc5e --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_docs-block.scss @@ -0,0 +1,176 @@ +.docs-block { + border: 1px solid $color--light-blue; + + @include media-query(tablet-portrait) { + max-width: 610px; + } + + &__header { + background-color: $color--light-blue; + color: $color--white; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + } + + &__heading { + margin: 0; + } + + &__inner { + padding: 1rem; + + @include media-query(tablet-portrait) { + padding: 2rem; + } + } + + &__row { + padding-bottom: 1rem; + margin-bottom: 1rem; + border-bottom: 1px solid $color--mid-grey; + + @include media-query(tablet-portrait) { + padding-bottom: 2rem; + margin-bottom: 2rem; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + &:last-child { + border-bottom: 0; + padding-bottom: 0; + margin-bottom: 0; + } + } + + &__row-inner { + display: flex; + align-items: center; + + &:first-child { + margin-bottom: 1rem; + } + + @include media-query(tablet-portrait) { + &:first-child { + margin: 0 1rem 0 0; + flex: 1; + } + } + } + + &__title { + margin: 0; + font-size: 1.2rem; + + @include media-query(tablet-landscape) { + flex: 1; + font-size: 1.5rem; + } + } + + &__icon { + width: 20px; + height: 20px; + stroke: $color--mid-grey; + fill: transparent; + margin-right: .5rem; + + @include media-query(tablet-landscape) { + margin-right: 1rem; + width: 30px; + height: 30px; + } + + &.is-complete { + stroke: $color--light-blue; + } + } + + &__link { + font-weight: $weight--bold; + margin-right: 1rem; + + &:last-child { + margin-right: 0; + } + } + + &__info-text { + @include media-query(tablet-landscape) { + max-width: 65%; + margin-left: 3rem; + } + } + + &__buttons { + padding: 0 1rem 1rem; + + @include media-query(tablet-portrait) { + padding: 0 2rem 2rem; + } + + .button { + padding: .5rem 1rem; + margin: 0 .5rem .5rem 0; + + &:last-child { + margin: 0; + } + } + } + + &__document-list { + width: 100%; + margin-top: 1.5rem; + padding-left: 0; + + @include media-query(tablet-landscape) { + padding-left: 3rem; + } + } + + &__document { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 1rem; + margin: 1rem 0; + border-bottom: 1px solid $color--light-mid-grey; + flex-wrap: wrap; + + &:last-child { + padding-bottom: 0; + margin: 0; + border-bottom: 0; + } + } + + &__document-inner { + &:first-child { + margin: 0 1rem 1rem 0; + } + + @include media-query(tablet-portrait) { + &:first-child { + margin: 0 1rem 0 0; + } + } + } + + &__document-info { + margin: 0; + } + + &__document-link { + margin-right: 1rem; + + &:last-child { + margin-right: 0; + } + } +} diff --git a/opentech/static_src/src/sass/apply/components/_funding-block.scss b/opentech/static_src/src/sass/apply/components/_funding-block.scss new file mode 100644 index 0000000000000000000000000000000000000000..8f18deb78f16368a959a87e627cbe8759cc10567 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_funding-block.scss @@ -0,0 +1,46 @@ +.funding-block { + background-color: $color--light-blue; + color: $color--white; + padding: 1rem; + margin-bottom: 1rem; + + @include media-query(mob-landscape) { + padding: 2rem; + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } + + @include media-query(tablet-portrait) { + max-width: 610px; + } + + &__item { + margin: 0 .5rem 1rem 0; + + @include media-query(mob-landscape) { + margin: 0 .5rem 0 0; + } + + &:last-child { + margin: 0; + } + } + + &__title { + margin: 0; + } + + &__standout { + margin: 0; + font-size: map-get($font-sizes, delta); + + @include media-query(small-tablet) { + font-size: map-get($font-sizes, beta); + } + } + + &__meta { + margin: 0; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_grid.scss b/opentech/static_src/src/sass/apply/components/_grid.scss index f2196f68632d62f65512d353ed5ca064e947c2b1..23e966a9040b3dbf904e86753fd9c696db540877 100644 --- a/opentech/static_src/src/sass/apply/components/_grid.scss +++ b/opentech/static_src/src/sass/apply/components/_grid.scss @@ -62,7 +62,10 @@ .wrapper--comments & { max-width: 800px; margin-bottom: 0; - grid-template-columns: repeat(auto-fit, 200px 200px); + + @include media-query(mob-landscape) { + grid-template-columns: repeat(auto-fit, 200px 200px); + } } + br { @@ -75,14 +78,12 @@ } &--proposal-info { - padding-bottom: 30px; - margin: 0 0 30px; - border-bottom: 1px solid $color--mid-grey; grid-template-columns: 100%; - grid-gap: 10px; // sass-lint:disable-line no-misspelled-properties + grid-gap: 10px; + margin: 0 0 1rem; @include media-query(mob-landscape) { - margin: 0 0 30px; + margin: 0 0 1.5rem; grid-template-columns: 1fr 1fr; } diff --git a/opentech/static_src/src/sass/apply/components/_invoice-block.scss b/opentech/static_src/src/sass/apply/components/_invoice-block.scss new file mode 100644 index 0000000000000000000000000000000000000000..d0bc7af63f87d31c016c72d8b0e7566df6416a0e --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_invoice-block.scss @@ -0,0 +1,36 @@ +.invoice-block { + padding: 1rem; + margin-bottom: 1rem; + border: 1px solid $color--dark-blue; + + @include media-query(mob-landscape) { + display: flex; + justify-content: space-between; + padding: 2rem; + } + + @include media-query(tablet-portrait) { + max-width: 610px; + } + + &__item { + margin-bottom: 1rem; + + &:last-child { + margin: 0; + } + + @include media-query(mob-landscape) { + margin: 0 1rem 0 0; + } + } + + &__title { + font-weight: $weight--bold; + margin: 0 0 .2rem; + } + + &__meta { + margin: 0; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_link.scss b/opentech/static_src/src/sass/apply/components/_link.scss index 35e89799fd7bd152bf6f498ca47b9ac1b828e1aa..195c1ca543f9b2a72ef2888ba5f1cb489562be2b 100644 --- a/opentech/static_src/src/sass/apply/components/_link.scss +++ b/opentech/static_src/src/sass/apply/components/_link.scss @@ -133,11 +133,13 @@ height: 30px; margin-left: 30px; font-size: 30px; - line-height: .9; - text-align: center; border: 2px solid $color--white; border-radius: 50%; content: '+'; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 4px; } } @@ -166,11 +168,13 @@ height: 30px; margin-left: 10px; font-size: 30px; - line-height: .1; - text-align: center; border: 2px solid $color--white; border-radius: 50%; - content: '_'; + content: '\2013'; + display: flex; + align-items: center; + justify-content: center; + padding-bottom: 5px; @include media-query(tablet-portrait) { margin-left: 20px; @@ -212,10 +216,6 @@ cursor: not-allowed; } - @include media-query(tablet-landscape) { - margin-left: auto; - } - &.is-active { color: $color--light-blue; @@ -225,8 +225,39 @@ } } + &--delete-submission { + margin-right: 1rem; + padding-right: 1rem; + border-right: 2px solid $color--mid-grey; + + &:only-child { + border-right: 0; + padding-right: 0; + margin-right: 0; + } + } + &--toggle-reviewers { display: block; margin: 0 10px 30px; } + + &--reveal-proposal { + display: flex; + align-items: center; + margin: 0 0 1rem; + + &::before { + @include triangle(top, $color--dark-blue, 7px); + margin-right: .7rem; + transition: transform $transition; + transform: rotate(180deg); + } + + &.is-open { + &::before { + transform: rotate(0); + } + } + } } diff --git a/opentech/static_src/src/sass/apply/components/_payment-block.scss b/opentech/static_src/src/sass/apply/components/_payment-block.scss new file mode 100644 index 0000000000000000000000000000000000000000..1cb21d1282882f263f601bfdaecb3f2b260936b3 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss @@ -0,0 +1,157 @@ +.payment-block { + border: 1px solid $color--mid-grey; + padding: 1rem; + margin-bottom: 1rem; + + @include media-query(mob-landscape) { + padding: 2rem; + } + + @include media-query(tablet-portrait) { + max-width: 610px; + } + + &__header { + margin-bottom: 1rem; + + @include media-query(tablet-portrait) { + margin-bottom: 1.5rem; + } + + @include media-query(mob-landscape) { + display: flex; + justify-content: space-between; + align-items: center; + } + } + + &__title { + font-size: map-get($font-sizes, delta); + margin: 0 0 1rem; + + @include media-query(mob-landscape) { + margin: 0; + } + } + + &__button { + padding: .7rem 1.2rem; + } + + &__status { + margin: 0; + + @include media-query(tablet-landscape) { + display: block; + font-weight: $weight--bold; + } + } + + &__status-link { + font-size: map-get($font-sizes, milli); + } + + &__rejected { + text-align: center; + } + + &__rejected-link { + font-weight: $weight--bold; + } + + &__mobile-label { + display: inline-block; + font-weight: $weight--bold; + white-space: pre; + + @include media-query(tablet-landscape) { + display: none; + } + } + + &__table { + thead { + display: none; + border-top: 2px solid $color--light-mid-grey; + + @include media-query(tablet-landscape) { + display: table-header-group; + } + + th { + color: $color--mid-dark-grey; + padding: 10px; + + @include media-query(tablet-landscape) { + text-align: center; + } + } + + tr { + border-color: $color--light-mid-grey; + } + } + + tbody { + font-size: map-get($font-sizes, zeta); + + a { + text-decoration: underline; + } + } + + tr { + border: 0; + border-bottom: 2px solid $color--light-grey; + + &:hover { + box-shadow: none; + } + + td { + padding: 0 0 .5rem; + word-break: break-word; + + &:first-child { + padding: 1rem 0 .5rem; + + @include media-query(tablet-landscape) { + padding: 1rem; + } + } + + &:last-child { + padding: 0 0 1rem; + + @include media-query(tablet-landscape) { + padding: 1rem; + } + } + + @include media-query(tablet-landscape) { + padding: 1rem; + } + } + } + } + + &__table-amount { + width: 20%; + min-width: 90px; + } + + &__table-status { + min-width: 160px; + width: 35%; + } + + &__table-docs { + min-width: 180px; + width: 20%; + } + + &__table-update { + min-width: 160px; + width: 25%; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_related-sidebar.scss b/opentech/static_src/src/sass/apply/components/_related-sidebar.scss index 582ebe17f3c0863574f7b8dd8b4b31f57e87b254..feb8eb4bbc363b111ff9f9b07e1ccdec1b757434 100644 --- a/opentech/static_src/src/sass/apply/components/_related-sidebar.scss +++ b/opentech/static_src/src/sass/apply/components/_related-sidebar.scss @@ -1,6 +1,7 @@ .related-sidebar { ul { list-style-type: circle; + padding-left: 20px; } &--collaps { diff --git a/opentech/static_src/src/sass/apply/components/_rich-text.scss b/opentech/static_src/src/sass/apply/components/_rich-text.scss index 057a3d218b9ef1e905bf9b276cfa75b08c869117..256965df61c030bd864de3f4d120b78ae97e2624 100644 --- a/opentech/static_src/src/sass/apply/components/_rich-text.scss +++ b/opentech/static_src/src/sass/apply/components/_rich-text.scss @@ -20,6 +20,14 @@ } } + &--hidden { + display: none; + + &.is-open { + display: block; + } + } + a { font-weight: $weight--bold; border-bottom: 1px solid transparent; diff --git a/opentech/static_src/src/sass/apply/components/_stat-block.scss b/opentech/static_src/src/sass/apply/components/_stat-block.scss new file mode 100644 index 0000000000000000000000000000000000000000..7441e11b24797eb6c842174d3e31a0a686f6409d --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_stat-block.scss @@ -0,0 +1,72 @@ +.stat-block { + $root: &; + + @include media-query(tablet-portrait) { + display: flex; + } + + &__item { + border: 1px solid $color--mid-dark-grey; + padding: 1.5rem; + background-color: $color--white; + flex: 1; + display: block; + margin: 0 0 1rem; + + @include media-query(tablet-portrait) { + margin: 0 1rem 0 0; + padding: 1.9rem 2.5rem; + } + + @include media-query(tablet-landscape) { + margin: 0 2rem 0 0; + } + + &:last-child { + margin-right: 0; + } + + &:only-child { + @include media-query(tablet-portrait) { + max-width: calc(100% / 3); + } + } + } + + &__number { + @extend %h1; + line-height: 1; + margin: 0 0 .7rem; + color: $color--marine; + } + + &__text { + font-weight: $weight--bold; + margin: 0 0 1rem; + color: $color--dark-grey; + + @include media-query(small-tablet) { + margin: 0 0 1.5rem; + font-size: map-get($font-sizes, delta); + } + } + + &__view { + text-transform: uppercase; + font-weight: $weight--bold; + letter-spacing: .5px; + transition: opacity $quick-transition; + + @include media-query(small-tablet) { + font-size: map-get($font-sizes, epsilon); + } + + @include media-query(tablet-portrait) { + opacity: 0; + + #{$root}__item:hover & { + opacity: 1; + } + } + } +} diff --git a/opentech/static_src/src/sass/apply/components/_status-bar.scss b/opentech/static_src/src/sass/apply/components/_status-bar.scss index f47f20dddf87d71230db88ae2aed25b3e843b77e..d47fea043db4c71d543fecd028779aea741e826f 100644 --- a/opentech/static_src/src/sass/apply/components/_status-bar.scss +++ b/opentech/static_src/src/sass/apply/components/_status-bar.scss @@ -185,8 +185,12 @@ .status-bar__item:last-of-type & { left: -45px; - @include media-query(desktop) { - left: -25px; + @include media-query(tablet-portrait) { + left: -60px; + } + + @include media-query(desktop-medium) { + left: -35px; } } diff --git a/opentech/static_src/src/sass/apply/components/_status-block.scss b/opentech/static_src/src/sass/apply/components/_status-block.scss index f7acc3acce0f476492125273479f0a51eff9e9d1..2657866ac39c119d3e051a081eafa85c596c1f28 100644 --- a/opentech/static_src/src/sass/apply/components/_status-block.scss +++ b/opentech/static_src/src/sass/apply/components/_status-block.scss @@ -57,6 +57,7 @@ &__title { font-weight: $weight--semibold; width: 100%; + hyphens: auto; } &__link { diff --git a/opentech/static_src/src/sass/apply/components/_wrapper.scss b/opentech/static_src/src/sass/apply/components/_wrapper.scss index e7165e01b4cd95706618d388763e6ce4601e6f5f..1a6c227b6ef04de9ae45227eced7bca259d355cb 100644 --- a/opentech/static_src/src/sass/apply/components/_wrapper.scss +++ b/opentech/static_src/src/sass/apply/components/_wrapper.scss @@ -280,4 +280,17 @@ justify-content: flex-end; } } + + &--submission-actions { + margin-left: auto; + display: flex; + margin-top: 1rem; + align-items: flex-start; + + @include media-query(tablet-landscape) { + margin-top: 0; + justify-content: flex-end; + flex: 1; + } + } } diff --git a/opentech/static_src/src/sass/apply/fancybox.scss b/opentech/static_src/src/sass/apply/fancybox.scss index e01a47923d6f75200525d969ec325368fda585f0..086643bb3344899d514d2ecf8f5e1404b108e6d8 100644 --- a/opentech/static_src/src/sass/apply/fancybox.scss +++ b/opentech/static_src/src/sass/apply/fancybox.scss @@ -325,10 +325,6 @@ body.fancybox-iosfix { position: relative; overflow: visible; shape-rendering: geometricPrecision; - - .modal--secondary & { - display: none; - } } .fancybox-button svg path { @@ -398,8 +394,8 @@ body.fancybox-iosfix { .fancybox-close-small { position: absolute; - top: 0; - right: 0; + top: 5px; + right: 5px; width: 40px; height: 40px; padding: 0; @@ -411,32 +407,6 @@ body.fancybox-iosfix { cursor: pointer; } -.fancybox-close-small:after { - content: '×'; - position: absolute; - top : 5px; - right: 5px; - width: 30px; - height: 30px; - font: 22px/30px Arial,"Helvetica Neue",Helvetica,sans-serif; - color: #888; - font-weight: 300; - text-align: center; - border-radius: 50%; - border-width: 0; - background-color: transparent; - transition: background-color .25s; - box-sizing: border-box; - z-index: 2; - - .modal--secondary & { - color: white; - font-size: 40px; - margin-top: 12px; - margin-right: 8px; - } -} - .fancybox-close-small:focus { outline: none; } diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss index 6a99196364afd0f89d1b8f0dde26b10fc8163f8f..3cb623c756ca2c22160742324637992d989b40ad 100644 --- a/opentech/static_src/src/sass/apply/main.scss +++ b/opentech/static_src/src/sass/apply/main.scss @@ -52,6 +52,11 @@ @import 'components/wrapper'; @import 'components/revisions'; @import 'components/messages'; +@import 'components/stat-block'; +@import 'components/docs-block'; +@import 'components/funding-block'; +@import 'components/payment-block'; +@import 'components/invoice-block'; // Layout @import 'layout/header'; 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'):