From 75feaa649a7d6fcd9c7e1c00273545209717cfb5 Mon Sep 17 00:00:00 2001 From: Michael Hall Date: Mon, 6 Aug 2018 11:08:51 -0400 Subject: [PATCH] Add ability to invite teams to an org, or request to have your team added to an org --- events/admin.py | 6 + events/forms.py | 22 +- .../0040_add_org_membership_requests.py | 49 ++++ events/models/profiles.py | 39 +++- events/templates/events/org_invite_form.html | 3 + events/templates/events/org_request_form.html | 3 + .../emails/orgs/invite_to_org.html | 12 + .../emails/orgs/invite_to_org.txt | 9 + .../emails/orgs/request_to_org.html | 12 + .../emails/orgs/request_to_org.txt | 9 + .../get_together/orgs/accept_invite.html | 15 ++ .../get_together/orgs/accept_request.html | 15 ++ .../get_together/orgs/invite_to_join.html | 14 ++ .../get_together/orgs/request_to_join.html | 14 ++ .../templates/get_together/orgs/show_org.html | 7 +- .../get_together/teams/team_page_base.html | 5 +- get_together/urls.py | 3 + get_together/views/orgs.py | 220 +++++++++++++++++- 18 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 events/migrations/0040_add_org_membership_requests.py create mode 100644 events/templates/events/org_invite_form.html create mode 100644 events/templates/events/org_request_form.html create mode 100644 get_together/templates/get_together/emails/orgs/invite_to_org.html create mode 100644 get_together/templates/get_together/emails/orgs/invite_to_org.txt create mode 100644 get_together/templates/get_together/emails/orgs/request_to_org.html create mode 100644 get_together/templates/get_together/emails/orgs/request_to_org.txt create mode 100644 get_together/templates/get_together/orgs/accept_invite.html create mode 100644 get_together/templates/get_together/orgs/accept_request.html create mode 100644 get_together/templates/get_together/orgs/invite_to_join.html create mode 100644 get_together/templates/get_together/orgs/request_to_join.html diff --git a/events/admin.py b/events/admin.py index afa1670..2cdada2 100644 --- a/events/admin.py +++ b/events/admin.py @@ -6,6 +6,7 @@ from .models.locale import Language, Continent, Country, SPR, City from .models.profiles import ( UserProfile, Organization, + OrgTeamRequest, Team, Member, Category, @@ -56,6 +57,11 @@ class OrgAdmin(admin.ModelAdmin): list_display = ('name', 'slug', 'site') admin.site.register(Organization, OrgAdmin) +class OrgRequestAdmin(admin.ModelAdmin): + list_display = ('organization', 'team', 'request_origin', 'requested_by', 'requested_date', 'accepted_by', 'joined_date') + list_filter = ('organization', 'request_origin') +admin.site.register(OrgTeamRequest, OrgRequestAdmin) + class SponsorAdmin(admin.ModelAdmin): list_display = ('name', 'web_url') admin.site.register(Sponsor, SponsorAdmin) diff --git a/events/forms.py b/events/forms.py index ed76f2c..f4b5fee 100644 --- a/events/forms.py +++ b/events/forms.py @@ -7,7 +7,7 @@ from django.utils import timezone from django.contrib.auth.models import User from .models.locale import Country, SPR, City -from .models.profiles import Team, UserProfile, Sponsor +from .models.profiles import Team, UserProfile, Sponsor, OrgTeamRequest from .models.events import ( Event, EventComment, @@ -381,6 +381,26 @@ class OrganizationForm(forms.ModelForm): 'cover_img', ] +class RequestToJoinOrgForm(forms.ModelForm): + class Meta: + model = OrgTeamRequest + fields = [ + 'team' + ] + +class AcceptRequestToJoinOrgForm(forms.Form): + confirm = forms.BooleanField(label="Yes, add this team to my organization", required=True) + +class InviteToJoinOrgForm(forms.ModelForm): + class Meta: + model = OrgTeamRequest + fields = [ + 'organization' + ] + +class AcceptInviteToJoinOrgForm(forms.Form): + confirm = forms.BooleanField(label="Yes, add my team to this organization", required=True) + class NewCommonEventForm(forms.ModelForm): class Meta: model = CommonEvent diff --git a/events/migrations/0040_add_org_membership_requests.py b/events/migrations/0040_add_org_membership_requests.py new file mode 100644 index 0000000..8d3a22b --- /dev/null +++ b/events/migrations/0040_add_org_membership_requests.py @@ -0,0 +1,49 @@ +# Generated by Django 2.0 on 2018-08-05 17:58 + +import datetime +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0039_add_profile_privacy_option'), + ] + + operations = [ + migrations.CreateModel( + name='OrgTeamRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('request_origin', models.SmallIntegerField(choices=[(0, 'Organization'), (1, 'Team')], db_index=True, default=0, verbose_name='Request from')), + ('request_key', models.UUIDField(default=uuid.uuid4)), + ('requested_date', models.DateTimeField(default=datetime.datetime.now)), + ('joined_date', models.DateTimeField(blank=True, null=True)), + ('accepted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_org_memberships', to='events.UserProfile')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.Organization')), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_org_memberships', to='events.UserProfile')), + ], + ), + migrations.AlterField( + model_name='event', + name='sponsors', + field=models.ManyToManyField(blank=True, related_name='events', to='events.Sponsor'), + ), + migrations.AlterField( + model_name='team', + name='premium_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='premium_teams', to='events.UserProfile'), + ), + migrations.AlterField( + model_name='team', + name='sponsors', + field=models.ManyToManyField(blank=True, related_name='teams', to='events.Sponsor'), + ), + migrations.AddField( + model_name='orgteamrequest', + name='team', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.Team'), + ), + ] diff --git a/events/models/profiles.py b/events/models/profiles.py index bde610a..d57b407 100644 --- a/events/models/profiles.py +++ b/events/models/profiles.py @@ -83,13 +83,22 @@ class UserProfile(models.Model): local = self.timezone.localize(dt) return local.astimezone(pytz.utc) + + @property + def is_a_team_admin(self): + return Member.objects.filter(user=self, role=Member.ADMIN).count() > 0 + @property def administering(self): - return [member.team for member in Member.objects.filter(user=self, role=Member.ADMIN)] + return [member.team for member in Member.objects.filter(user=self, role=Member.ADMIN).order_by('team__name')] + + @property + def is_a_team_moderator(self): + return Member.objects.filter(user=self, role__in=(Member.ADMIN, Member.MODERATOR)).count() > 0 @property def moderating(self): - return [member.team for member in Member.objects.filter(user=self, role__in=(Member.ADMIN, Member.MODERATOR))] + return [member.team for member in Member.objects.filter(user=self, role__in=(Member.ADMIN, Member.MODERATOR)).order_by('team__name')] def can_create_event(self, team): try: @@ -241,6 +250,32 @@ class Organization(models.Model): def __str__(self): return u'%s' % (self.name) +class OrgTeamRequest(models.Model): + ORG=0 + TEAM=1 + ORIGINS = [ + (ORG, _("Organization")), + (TEAM, _("Team")), + ] + + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + team = models.ForeignKey('Team', on_delete=models.CASCADE) + request_origin = models.SmallIntegerField(_("Request from"), choices=ORIGINS, default=ORG, db_index=True) + request_key = models.UUIDField(default=uuid.uuid4, editable=True) + + requested_by = models.ForeignKey(UserProfile, related_name='requested_org_memberships', on_delete=models.SET_NULL, null=True, blank=False) + requested_date = models.DateTimeField(default=datetime.datetime.now) + accepted_by = models.ForeignKey(UserProfile, related_name='accepted_org_memberships', on_delete=models.SET_NULL, null=True, blank=True) + joined_date = models.DateTimeField(null=True, blank=True) + + @property + def origin_name(self): + return OrgTeamRequest.ORIGINS[self.request_origin][1] + + def __str__(self): + return '%s in %s' % (self.team, self.organization) + + class Sponsor(models.Model): name = models.CharField(_("Sponsor Name"), max_length=256, null=False, blank=False) description = models.TextField(blank=True, null=True) diff --git a/events/templates/events/org_invite_form.html b/events/templates/events/org_invite_form.html new file mode 100644 index 0000000..7a110fb --- /dev/null +++ b/events/templates/events/org_invite_form.html @@ -0,0 +1,3 @@ + +{{ invite_form }} +
diff --git a/events/templates/events/org_request_form.html b/events/templates/events/org_request_form.html new file mode 100644 index 0000000..0471bfc --- /dev/null +++ b/events/templates/events/org_request_form.html @@ -0,0 +1,3 @@ + +{{ request_form }} +
diff --git a/get_together/templates/get_together/emails/orgs/invite_to_org.html b/get_together/templates/get_together/emails/orgs/invite_to_org.html new file mode 100644 index 0000000..9012acf --- /dev/null +++ b/get_together/templates/get_together/emails/orgs/invite_to_org.html @@ -0,0 +1,12 @@ +{% extends "get_together/emails/base.html" %} + +{% block content %} +

You've been invited to join {{org.name|striptags}}

+ +

{{ sender }} has invited your team, {{team.name}}, to join their Get Together organization: {{org.name}}. +

+ +
+Click here to view and confirm this invitation. +

+{% endblock %} diff --git a/get_together/templates/get_together/emails/orgs/invite_to_org.txt b/get_together/templates/get_together/emails/orgs/invite_to_org.txt new file mode 100644 index 0000000..e35ce0e --- /dev/null +++ b/get_together/templates/get_together/emails/orgs/invite_to_org.txt @@ -0,0 +1,9 @@ +{% extends 'get_together/emails/base.txt' %} +{% block content %} +== You've been invited to join {{org.name}} == + +{{ sender }} has invited your team, {{team.name}}, to join their Get Together organization: {{org.name}}. + +Click here to view and confirm this invitation: https://{{site.domain}}{% url 'confirm-request-to-join-org' req.request_key %} + +{% endblock %} diff --git a/get_together/templates/get_together/emails/orgs/request_to_org.html b/get_together/templates/get_together/emails/orgs/request_to_org.html new file mode 100644 index 0000000..bc9e718 --- /dev/null +++ b/get_together/templates/get_together/emails/orgs/request_to_org.html @@ -0,0 +1,12 @@ +{% extends "get_together/emails/base.html" %} + +{% block content %} +

{{team.name|striptags}} has requested to join your organization.

+ +

{{ sender }} has requested to have their team, {{team.name}}, added to your Get Together organization: {{org.name}}. +

+ +
+Click here to view and confirm this request. +

+{% endblock %} diff --git a/get_together/templates/get_together/emails/orgs/request_to_org.txt b/get_together/templates/get_together/emails/orgs/request_to_org.txt new file mode 100644 index 0000000..ae76c50 --- /dev/null +++ b/get_together/templates/get_together/emails/orgs/request_to_org.txt @@ -0,0 +1,9 @@ +{% extends 'get_together/emails/base.txt' %} +{% block content %} +== {{team.name}} has requested to join your organization. == + +{{ sender }} has requested to have their team, {{team.name}}, added to your Get Together organization: {{org.name}}. + +Click here to view and confirm this request: https://{{site.domain}}{% url 'confirm-request-to-join-org' req.request_key %} + +{% endblock %} diff --git a/get_together/templates/get_together/orgs/accept_invite.html b/get_together/templates/get_together/orgs/accept_invite.html new file mode 100644 index 0000000..09f32a6 --- /dev/null +++ b/get_together/templates/get_together/orgs/accept_invite.html @@ -0,0 +1,15 @@ +{% extends "get_together/base.html" %} +{% load static %} +{% block content %} +

{{team.name}} has been invited to join {{org.name}}

+

Would you like to join this organization?

+
+{% csrf_token %} +{% include "events/org_invite_form.html" %} +
+ +Decline +
+{% endblock %} + + diff --git a/get_together/templates/get_together/orgs/accept_request.html b/get_together/templates/get_together/orgs/accept_request.html new file mode 100644 index 0000000..74dab4f --- /dev/null +++ b/get_together/templates/get_together/orgs/accept_request.html @@ -0,0 +1,15 @@ +{% extends "get_together/base.html" %} +{% load static %} +{% block content %} +

{{team.name}} has asked to join {{org.name}}

+

Would you like to add this team?

+
+{% csrf_token %} +{% include "events/org_request_form.html" %} +
+ +Decline +
+{% endblock %} + + diff --git a/get_together/templates/get_together/orgs/invite_to_join.html b/get_together/templates/get_together/orgs/invite_to_join.html new file mode 100644 index 0000000..40de13b --- /dev/null +++ b/get_together/templates/get_together/orgs/invite_to_join.html @@ -0,0 +1,14 @@ +{% extends "get_together/base.html" %} +{% load static %} +{% block content %} +

Invite: {{team.name}}

+

Select which of your organizations you would like to invite this team to.

+
+{% csrf_token %} +{% include "events/org_invite_form.html" %} +
+ +
+{% endblock %} + + diff --git a/get_together/templates/get_together/orgs/request_to_join.html b/get_together/templates/get_together/orgs/request_to_join.html new file mode 100644 index 0000000..9aa8297 --- /dev/null +++ b/get_together/templates/get_together/orgs/request_to_join.html @@ -0,0 +1,14 @@ +{% extends "get_together/base.html" %} +{% load static %} +{% block content %} +

Request to join: {{org.name}}

+

Select which of your teams you would like to add to this organization.

+
+{% csrf_token %} +{% include "events/org_request_form.html" %} +
+ +
+{% endblock %} + + diff --git a/get_together/templates/get_together/orgs/show_org.html b/get_together/templates/get_together/orgs/show_org.html index 8ce8232..e0262b6 100644 --- a/get_together/templates/get_together/orgs/show_org.html +++ b/get_together/templates/get_together/orgs/show_org.html @@ -23,7 +23,7 @@
{% if can_edit_org %} {% endif %} @@ -35,6 +35,11 @@ {% else %}

Welcome to {{ org.name }}

{% endif %} +
+ {% if request.user.profile.is_a_team_admin %} + Join Organization + {% endif %} +
{% if org.description %}
diff --git a/get_together/templates/get_together/teams/team_page_base.html b/get_together/templates/get_together/teams/team_page_base.html index d6ac4e1..c6c5de3 100644 --- a/get_together/templates/get_together/teams/team_page_base.html +++ b/get_together/templates/get_together/teams/team_page_base.html @@ -23,8 +23,11 @@
{% if can_edit_team %}
- Edit Team + Edit Team Manage Members + {% if request.user.profile.owned_orgs.count > 0 %} + Add to Organization + {% endif %}
{% endif %} {% if team.banner_img %} diff --git a/get_together/urls.py b/get_together/urls.py index f665d36..f2d1ed6 100644 --- a/get_together/urls.py +++ b/get_together/urls.py @@ -101,6 +101,9 @@ urlpatterns = [ path('org//', views.show_org, name='show-org'), path('org//+edit/', views.edit_org, name='edit-org'), + path('team//+invite_to_join_org/', views.invite_to_join_org, name='invite-to-join-org'), + path('org//+request_to_join_org/', views.request_to_join_org, name='request-to-join-org'), + path('org/+confirm_request//', views.confirm_request_to_join_org, name='confirm-request-to-join-org'), path('org//+create-event/', views.create_common_event, name='create-common-event'), path('common//+create-event/', views.create_common_event_team_select, name='create-common-event-team-select'), path('common///', views.show_common_event, name='show-common-event'), diff --git a/get_together/views/orgs.py b/get_together/views/orgs.py index b17f544..b79cc55 100644 --- a/get_together/views/orgs.py +++ b/get_together/views/orgs.py @@ -11,9 +11,9 @@ from django.template.loader import get_template, render_to_string from django.conf import settings -from events.models.profiles import Organization, Team, UserProfile, Member +from events.models.profiles import Organization, Team, UserProfile, Member, OrgTeamRequest from events.models.events import Event, CommonEvent, Place, Attendee -from events.forms import OrganizationForm, NewCommonEventForm +from events.forms import OrganizationForm, NewCommonEventForm, RequestToJoinOrgForm, InviteToJoinOrgForm, AcceptRequestToJoinOrgForm, AcceptInviteToJoinOrgForm from events import location from events.utils import slugify @@ -69,6 +69,222 @@ def edit_org(request, org_slug): return redirect('home') +@login_required +def request_to_join_org(request, org_slug): + org = get_object_or_404(Organization, slug=org_slug) + if not len(request.user.profile.administering) > 0: + messages.add_message(request, messages.WARNING, message=_('You are not the administrator for any teams.')) + return redirect('show-org', org_slug=org.slug) + + req = OrgTeamRequest(organization=org, request_origin=OrgTeamRequest.TEAM, requested_by=request.user.profile) + if request.method == 'GET': + form = RequestToJoinOrgForm(instance=req) + form.fields['team'].queryset = Team.objects.filter(member__user=request.user.profile, member__role=Member.ADMIN).order_by('name') + + context = { + 'org': org, + 'request_form': form, + } + return render(request, 'get_together/orgs/request_to_join.html', context) + elif request.method == 'POST': + form = RequestToJoinOrgForm(request.POST, instance=req) + form.fields['team'].queryset = Team.objects.filter(member__user=request.user.profile, member__role=Member.ADMIN).order_by('name') + if form.is_valid(): + req = form.save() + send_org_request(req) + messages.add_message(request, messages.SUCCESS, message=_('Your request has been send to the organization administrators.')) + return redirect('show-org', org_slug=org.slug) + else: + context = { + 'org': org, + 'request_form': form, + } + return render(request, 'get_together/orgs/request_to_join.html', context) + else: + return redirect('home') + + +def send_org_request(req): + context = { + 'sender': req.requested_by, + 'req': req, + 'org': req.organization, + 'team': req.team, + 'site': Site.objects.get(id=1), + } + email_subject = 'Request to join: %s' % req.team.name + email_body_text = render_to_string('get_together/emails/orgs/request_to_org.txt', context) + email_body_html = render_to_string('get_together/emails/orgs/request_to_org.html', context) + email_from = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@gettogether.community') + + admin = req.organization.owner_profile + success = send_mail( + from_email=email_from, + html_message=email_body_html, + message=email_body_text, + recipient_list=[admin.user.email], + subject=email_subject, + fail_silently=True, + ) + EmailRecord.objects.create( + sender=req.requested_by.user, + recipient=admin.user, + email=admin.user.email, + subject=email_subject, + body=email_body_text, + ok=success + ) + + +@login_required +def invite_to_join_org(request, team_id): + team = get_object_or_404(Team, id=team_id) + if not request.user.profile.owned_orgs.count() > 0: + messages.add_message(request, messages.WARNING, message=_('You are not the administrator for any organizations.')) + return redirect('show-team', team_id=team_id) + + invite = OrgTeamRequest(team=team, request_origin=OrgTeamRequest.ORG, requested_by=request.user.profile) + if request.method == 'GET': + form = InviteToJoinOrgForm(instance=invite) + form.fields['organization'].queryset = Organization.objects.filter(owner_profile=request.user.profile).order_by('name') + + context = { + 'team': team, + 'invite_form': form, + } + return render(request, 'get_together/orgs/invite_to_join.html', context) + elif request.method == 'POST': + form = InviteToJoinOrgForm(request.POST, instance=invite) + if form.is_valid(): + invite = form.save() + send_org_invite(invite) + messages.add_message(request, messages.SUCCESS, message=_('Your request has been send to the team administrators.')) + return redirect('show-team', team_id=team_id) + else: + context = { + 'team': team, + 'invite_form': form, + } + return render(request, 'get_together/orgs/invite_to_join.html', context) + else: + return redirect('home') + + +def send_org_invite(req): + context = { + 'sender': req.requested_by, + 'req': req, + 'org': req.organization, + 'team': req.team, + 'site': Site.objects.get(id=1), + } + email_subject = 'Invitation to join: %s' % req.organization.name + email_body_text = render_to_string('get_together/emails/orgs/invite_to_org.txt', context) + email_body_html = render_to_string('get_together/emails/orgs/invite_to_org.html', context) + email_from = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@gettogether.community') + + for admin in Member.objects.filter(team=req.team, role=Member.ADMIN, user__user__account__is_email_confirmed=True): + success = send_mail( + from_email=email_from, + html_message=email_body_html, + message=email_body_text, + recipient_list=[admin.user.user.email], + subject=email_subject, + fail_silently=True, + ) + EmailRecord.objects.create( + sender=req.requested_by.user, + recipient=admin.user.user, + email=admin.user.user.email, + subject=email_subject, + body=email_body_text, + ok=success + ) + + +@login_required +def confirm_request_to_join_org(request, request_key): + req = get_object_or_404(OrgTeamRequest, request_key=request_key) + if req.request_origin == req.ORG: + return accept_invite_to_join_org(request, req) + else: + return accept_request_to_join_org(request, req) + +@login_required +def accept_request_to_join_org(request, req): + if not request.user.profile.can_edit_org(req.organization): + messages.add_message(request, messages.WARNING, message=_('You do not have permission to accept new teams to this organization.')) + return redirect('show-org', org_slug=req.organization.slug) + + if request.method == 'GET': + form = AcceptRequestToJoinOrgForm() + + context = { + 'invite': req, + 'org': req.organization, + 'team': req.team, + 'request_form': form, + } + return render(request, 'get_together/orgs/accept_request.html', context) + elif request.method == 'POST': + form = AcceptRequestToJoinOrgForm(request.POST) + if form.is_valid() and form.cleaned_data['confirm']: + req.accepted_by = request.user.profile + req.joined_date = datetime.datetime.now() + req.save() + req.team.organization = req.organization + req.team.save() + messages.add_message(request, messages.SUCCESS, message=_('%s has been added to your organization.' % req.team.name)) + return redirect('show-org', org_slug=req.organization.slug) + else: + context = { + 'invite': req, + 'org': req.organization, + 'team': req.team, + 'request_form': form, + } + return render(request, 'get_together/orgs/accept_request.html', context) + else: + return redirect('home') + +@login_required +def accept_invite_to_join_org(request, req): + if not request.user.profile.can_edit_team(req.team): + messages.add_message(request, messages.WARNING, message=_('You do not have permission to add this team to an orgnization.')) + return redirect('show-team-by-slug', team_slug=req.team.slug) + + if request.method == 'GET': + form = AcceptInviteToJoinOrgForm() + + context = { + 'invite': req, + 'org': req.organization, + 'team': req.team, + 'invite_form': form, + } + return render(request, 'get_together/orgs/accept_invite.html', context) + elif request.method == 'POST': + form = AcceptInviteToJoinOrgForm(request.POST) + if form.is_valid() and form.cleaned_data['confirm']: + req.accepted_by = request.user.profile + req.joined_date = datetime.datetime.now() + req.save() + req.team.organization = req.organization + req.team.save() + messages.add_message(request, messages.SUCCESS, message=_('You team has been added to %s.' % req.organization.name)) + return redirect('show-team-by-slug', team_slug=req.team.slug) + else: + context = { + 'invite': req, + 'org': req.organization, + 'team': req.team, + 'invite_form': form, + } + return render(request, 'get_together/orgs/accept_invite.html', context) + else: + return redirect('home') + + def show_common_event(request, event_id, event_slug): event = get_object_or_404(CommonEvent, id=event_id) context = {