diff --git a/civictechprojects/helpers/context_preload.py b/civictechprojects/helpers/context_preload.py index 8b5732bfc..ec635db13 100644 --- a/civictechprojects/helpers/context_preload.py +++ b/civictechprojects/helpers/context_preload.py @@ -134,6 +134,12 @@ def my_groups_preload(context, request): context['description'] = 'My Groups page' return context +def group_projects_preload(context, request): + context = default_preload(context, request) + context['title'] = 'Participating Projects | DemocracyLab' + context['description'] = 'Projects that the group participate' + return context + def find_events_preload(context, request): context = default_preload(context, request) context['title'] = 'Find Events | DemocracyLab' @@ -229,6 +235,7 @@ def default_preload(context, request): {'section': FrontEndSection.CreateProject.value,'handler':create_project_preload}, {'section': FrontEndSection.MyProjects.value, 'handler': my_projects_preload}, {'section': FrontEndSection.MyGroups.value, 'handler': my_groups_preload}, + {'section': FrontEndSection.GroupProjects.value, 'handler': group_projects_preload}, {'section': FrontEndSection.MyEvents.value, 'handler': my_events_preload}, {'section': FrontEndSection.Donate.value, 'handler': donate_preload}, {'section': FrontEndSection.AboutGroup.value, 'handler': about_group_preload}, diff --git a/civictechprojects/helpers/search/projects.py b/civictechprojects/helpers/search/projects.py index ff748f700..7ce4a791c 100644 --- a/civictechprojects/helpers/search/projects.py +++ b/civictechprojects/helpers/search/projects.py @@ -204,6 +204,6 @@ def get_tag_counts(category=None, event=None, group=None): for slug in querydict.keys(): resultdict[slug] = Tag.hydrate_tag_model(querydict[slug]) resultdict[slug]["num_times"] = ( - activetagdict[slug] if slug in activetagdict else 0 + activetagdict[slug] if activetagdict and slug in activetagdict else 0 ) return list(resultdict.values()) diff --git a/civictechprojects/static/css/partials/_ConfirmRemoveGroupProjectModal.scss b/civictechprojects/static/css/partials/_ConfirmRemoveGroupProjectModal.scss new file mode 100644 index 000000000..ccd6e825f --- /dev/null +++ b/civictechprojects/static/css/partials/_ConfirmRemoveGroupProjectModal.scss @@ -0,0 +1,6 @@ +.ConfirmRemoveGroupProjectModal-buttons{ + display: flex; + flex-direction: column; + width: 100%; + gap: 12px; +} \ No newline at end of file diff --git a/civictechprojects/static/css/partials/_GroupProjectCard.scss b/civictechprojects/static/css/partials/_GroupProjectCard.scss new file mode 100644 index 000000000..ae5b9337a --- /dev/null +++ b/civictechprojects/static/css/partials/_GroupProjectCard.scss @@ -0,0 +1,53 @@ +.GroupProjectCard-root { + color: inherit; + background-color: $color-background-light; + border: solid 1px $color-grey-frame-border; + margin: 20px auto; + padding: 16px; + } + + .GroupProjectCard-header { + color: $color-text-dark; + font-style: italic; + } + + .GroupProjectCard-projectName { + font-weight: bold; + } + + .GroupProjectCard-item{ + margin-top: 10px; + } + .GroupProjectCard-button-container{ + display: flex; + flex-wrap: wrap; + justify-content: flex-start; + } + .GroupProjectCard-button { + margin: 5px; + } + + .GroupProjectCard-viewButton{ + margin-left: 0; + } + + .GroupProjectCard-table td { + display: inline-block; + width: 100%; + } + + @include media-breakpoint-up(sm) { + .GroupProjectCard-table td { + display: table-cell; + width: 250px; + } + .GroupProjectCard-item{ + margin-top: 0; + } + .GroupProjectCard-viewButton{ + margin-left: 5; + } + .GroupProjectCard-button-container{ + justify-content: flex-end; + } + } \ No newline at end of file diff --git a/civictechprojects/static/css/partials/_GroupProjectsController.scss b/civictechprojects/static/css/partials/_GroupProjectsController.scss new file mode 100644 index 000000000..dc2b7218d --- /dev/null +++ b/civictechprojects/static/css/partials/_GroupProjectsController.scss @@ -0,0 +1,16 @@ +.GroupProjectController-root { + background: $color-background-default; + h3 { + margin-top: 24px; + } +} +.GroupProjectController-header{ + display: flex; + flex-direction: row; + align-items: flex-end; +} +.GroupProjectController-return-group-button{ + margin-left: auto; + margin-bottom: 8px; + cursor: pointer; +} \ No newline at end of file diff --git a/civictechprojects/static/css/partials/_MyProjectCard.scss b/civictechprojects/static/css/partials/_MyProjectCard.scss index 3590093b2..3d2dbbe03 100644 --- a/civictechprojects/static/css/partials/_MyProjectCard.scss +++ b/civictechprojects/static/css/partials/_MyProjectCard.scss @@ -6,6 +6,12 @@ padding: 16px; } +.MyProjectCard-groupButtons{ + display: flex; + flex-wrap: wrap; + justify-content: flex-end; +} + .MyProjectCard-header { color: $color-text-dark; font-style: italic; @@ -28,5 +34,10 @@ .MyProjectCard-table td { display: table-cell; width: 250px; - } + } +} +@media only screen and (max-width: 1440px) { + .MyProjectCard-groupButtons{ + justify-content: flex-start; + } } \ No newline at end of file diff --git a/civictechprojects/static/css/partials/_Profile.scss b/civictechprojects/static/css/partials/_Profile.scss index fe71ae934..d2185561f 100644 --- a/civictechprojects/static/css/partials/_Profile.scss +++ b/civictechprojects/static/css/partials/_Profile.scss @@ -34,7 +34,13 @@ margin-left: auto; align-items: flex-end; } - +.Profile-owner-button-container{ + display: flex; + flex-direction: column; + gap: 12px; + flex-wrap: wrap; + align-items: flex-end; +} .Profile-top-details { flex-basis: 100%; order: 3; diff --git a/civictechprojects/static/css/partials/_Toasts.scss b/civictechprojects/static/css/partials/_Toasts.scss index e16805974..984f5a216 100644 --- a/civictechprojects/static/css/partials/_Toasts.scss +++ b/civictechprojects/static/css/partials/_Toasts.scss @@ -35,4 +35,14 @@ .toast { max-width: 400px; // this is a test of slightly wider toasts @ desktop viewports } +} + +/* The animation code */ +@keyframes slidein { + from {translate: 100vw;} + to {translate: 0%;} +} +/* The element to apply the animation to */ +.Toast-animation { + animation: 0.3s linear slidein; } \ No newline at end of file diff --git a/civictechprojects/static/css/styles.scss b/civictechprojects/static/css/styles.scss index bc27e0b21..3297647e4 100644 --- a/civictechprojects/static/css/styles.scss +++ b/civictechprojects/static/css/styles.scss @@ -48,6 +48,8 @@ @import "partials/SponsorFooter"; @import "partials/MyProjectCard"; @import "partials/MyProjectsController"; +@import "partials/GroupProjectCard"; +@import "partials/GroupProjectsController"; @import "partials/ProjectCard"; @import "partials/ProjectCardsContainer"; @import "partials/ProjectCardContainer"; @@ -100,6 +102,7 @@ @import "partials/Forms"; @import "partials/AboutProjectEventDisplay"; @import "partials/Toasts"; +@import "partials/ConfirmRemoveGroupProjectModal"; @import "partials/JoinConference"; @import "partials/Terms"; @import "partials/Privacy"; diff --git a/civictechprojects/tests/test_views.py b/civictechprojects/tests/test_views.py index df7674684..04aeaac76 100644 --- a/civictechprojects/tests/test_views.py +++ b/civictechprojects/tests/test_views.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.timezone import now -from civictechprojects.models import Group, Project +from civictechprojects.models import Group, Project, ProjectRelationship from democracylab.models import Contributor @@ -213,3 +213,39 @@ def test_api_views_throttling__project_delete(self): expect_succeeded = 10 if is_authenticated else 5 self.assertEqual(num_succeeded, expect_succeeded) self.assertEqual(num_throttled, 12-expect_succeeded) + + def test_api_views_throttling__group_project_remove(self): + for is_authenticated in {True, False}: + client = Client() + if is_authenticated: + client.force_login(self.test_user) + group = Group.objects.create( + group_creator=self.test_user, + group_name='test-name', + is_searchable=True, + ) + projects = Project.objects.bulk_create([ + Project( + project_creator=self.test_user, + project_name=f'test-name-{i}', + is_searchable=True, + ) for i in range(12) + ]) + # add project to group + relationshipProjects = ProjectRelationship.objects.bulk_create([ + ProjectRelationship( + relationship_group=group, + relationship_project=project, + is_approved=True, + ) for project in projects + ]) + num_succeeded, num_throttled = self.make_many_requests( + client=client, + method='post', + params=[{ + 'path': reverse('group_project_remove', kwargs={'project_id': project.id,'group_id':group.id}), + } for project in projects], + ) + expect_succeeded = 10 if is_authenticated else 5 + self.assertEqual(num_succeeded, expect_succeeded) + self.assertEqual(num_throttled, 12-expect_succeeded) \ No newline at end of file diff --git a/civictechprojects/urls.py b/civictechprojects/urls.py index fae049292..c538769a8 100644 --- a/civictechprojects/urls.py +++ b/civictechprojects/urls.py @@ -47,6 +47,7 @@ re_path(r'^api/groups/approve/(?P[0-9]+)/$', views.approve_group, name='approve_group'), re_path(r'^api/groups/edit/(?P[0-9]+)/$', views.group_edit, name='group_edit'), re_path(r'^api/groups/delete/(?P[0-9]+)/$', views.group_delete, name='group_delete'), + re_path(r'^api/groups/(?P.*)/projects/remove/(?P.*)/$', views.group_project_remove, name='group_project_remove'), re_path(r'^api/event/(?P.*)/projects/(?P.*)/create/$', views.event_project_edit, name='event_project_edit'), re_path(r'^api/event/(?P.*)/projects/(?P.*)/rsvp/$', views.rsvp_for_event_project, name='rsvp_for_event_project'), re_path(r'^api/event/(?P.*)/projects/(?P.*)/cancel/$', views.cancel_rsvp_for_event_project, name='cancel_rsvp_for_event_project'), diff --git a/civictechprojects/views.py b/civictechprojects/views.py index 7c74002bb..82c77e6e4 100644 --- a/civictechprojects/views.py +++ b/civictechprojects/views.py @@ -35,7 +35,7 @@ from democracylab.models import Contributor, get_request_contributor from common.models.tags import Tag from common.helpers.constants import FrontEndSection, TagCategory -from democracylab.emails import send_to_project_owners, send_to_project_volunteer, HtmlEmailTemplate, send_volunteer_application_email, \ +from democracylab.emails import send_to_project_owners, send_to_project_volunteer,Html, HtmlEmailTemplate, send_volunteer_application_email, \ send_volunteer_conclude_email, notify_project_owners_volunteer_renewed_email, notify_project_owners_volunteer_concluded_email, \ notify_project_owners_project_approved, contact_democracylab_email, send_to_group_owners, send_group_project_invitation_email, \ notify_group_owners_group_approved, notify_event_owners_event_approved, notify_rsvped_volunteer, notify_rsvp_cancellation, \ @@ -118,6 +118,35 @@ def group_delete(request, group_id): return HttpResponseForbidden() return HttpResponse(status=204) +@api_view(['POST']) +def group_project_remove(request, group_id,project_id): + user = request.user + message = request.data.get('message',"") + group = Group.objects.get(id=group_id) + project = Project.objects.get(id=project_id) + if not user.is_authenticated: + return HttpResponse(status=401) + # only creator or staff can remove the project + if not is_creator(user, group) and not user.is_staff: + return HttpResponse(status=403) + # Remove project relationship + project_relation = ProjectRelationship.objects.get(relationship_project=project_id) + project_relation.delete() + project.recache() + group.recache() + group.update_timestamp() + # set up mail + link_to_group = section_url(FrontEndSection.AboutGroup,{'id':group_id}) + link_to_project = section_url(FrontEndSection.AboutProject,{'id': project_id}) + email_subject = '{} has been removed from collaboration'.format(project.project_name) + email_template = HtmlEmailTemplate(use_signature=False) + email_template.header('{} has been removed from collaboration'.format(project.project_name)) + email_template.paragraph(Html.a(href=link_to_group,text=group.group_name) + ' has removed '+Html.a(href=link_to_project,text=project.project_name)+' from their group. If you have any questions, please contact the group owner via link below.') + if(len(message)>0): + email_template.paragraph('"{}"'.format(message)) + email_template.button(link_to_group,"CONTACT GROUP",text_color="#000000",text_decoration='none') + send_to_project_owners(project=project,sender=user,subject=email_subject,template=email_template,include_co_owners=False) + return HttpResponse(status=204) @api_view() def get_group(request, group_id): diff --git a/common/components/common/ModalWrapper.jsx b/common/components/common/ModalWrapper.jsx index b55d0ee5f..4b6e0fcff 100644 --- a/common/components/common/ModalWrapper.jsx +++ b/common/components/common/ModalWrapper.jsx @@ -26,6 +26,9 @@ type Props = {| hideButtons: ?boolean, size: ?string, reverseCancelConfirm: ?boolean, + cancelButtonVariant: ?string, + submitButtonVariant: ?string, + buttons: ?React$Node, |}; type State = {||}; @@ -57,9 +60,9 @@ class ModalWrapper extends React.PureComponent { {this.props.headerText} {this.props.children} - {!this.props.hideButtons && ( - {this.props.reverseCancelConfirm ? ( + {!this.props.hideButtons && (this.props.buttons)? this.props.buttons : ( + this.props.reverseCancelConfirm ? ( {this._renderSubmitButton()} {this.props.onClickCancel && this._renderCancelButton()} @@ -69,18 +72,19 @@ class ModalWrapper extends React.PureComponent { {this.props.onClickCancel && this._renderCancelButton()} {this._renderSubmitButton()} + ) )} - )} ); } _renderCancelButton(): React$Node { + const {cancelButtonVariant="outline-secondary"} = this.props; return ( + ); + } + _renderContactGroupButton(): React$Node { return (