Merge pull request #12 from pinax/feature-teams

Teams
This commit is contained in:
James Tauber 2012-08-02 22:35:35 -07:00
commit 687e3ace51
13 changed files with 562 additions and 0 deletions

View file

17
symposion/teams/admin.py Normal file
View file

@ -0,0 +1,17 @@
from django.contrib import admin
import reversion
from symposion.teams.models import Team, Membership
admin.site.register(Team,
prepopulated_fields={"slug": ("name",)},
)
class MembershipAdmin(reversion.VersionAdmin):
list_display = ["team", "user", "state"]
list_filter = ["team"]
search_fields = ["user__username"]
admin.site.register(Membership, MembershipAdmin)

View file

@ -0,0 +1,33 @@
from django.db.models import Q
from .models import Team
class TeamPermissionsBackend(object):
def authenticate(self, username=None, password=None):
return None
def get_team_permissions(self, user_obj, obj=None):
"""
Returns a set of permission strings that this user has through his/her
team memberships.
"""
if user_obj.is_anonymous() or obj is not None:
return set()
if not hasattr(user_obj, "_team_perm_cache"):
memberships = Team.objects.filter(
Q(memberships__user=user_obj),
Q(memberships__state="manager") | Q(memberships__state="member"),
)
perms = memberships.values_list(
"permissions__content_type__app_label",
"permissions__codename"
).order_by()
user_obj._team_perm_cache = set(["%s.%s" % (ct, name) for ct, name in perms])
return user_obj._team_perm_cache
def has_perm(self, user_obj, perm, obj=None):
if not user_obj.is_active:
return False
return perm in self.get_team_permissions(user_obj, obj)

50
symposion/teams/forms.py Normal file
View file

@ -0,0 +1,50 @@
from django import forms
from django.contrib.auth.models import User
from symposion.teams.models import Membership
class TeamInvitationForm(forms.Form):
email = forms.EmailField(help_text="email address must be that of a user on the site")
def __init__(self, *args, **kwargs):
self.team = kwargs.pop("team")
super(TeamInvitationForm, self).__init__(*args, **kwargs)
def clean(self):
cleaned_data = super(TeamInvitationForm, self).clean()
email = cleaned_data.get("email")
if email is None:
raise forms.ValidationError("valid email address required")
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
# eventually we can invite them but for now assume they are
# already on the site
raise forms.ValidationError("no known user with email address %s" % email)
state = self.team.get_state_for_user(user)
if state in ["member", "manager"]:
raise forms.ValidationError("user already in team")
if state in ["invited"]:
raise forms.ValidationError("user already invited to team")
self.user = user
self.state = state
return cleaned_data
def invite(self):
if self.state is None:
Membership.objects.create(team=self.team, user=self.user, state="invited")
elif self.state == "applied":
# if they applied we shortcut invitation process
membership = Membership.objects.get(team=self.team, user=self.user)
membership.state = "member"
membership.save()

66
symposion/teams/models.py Normal file
View file

@ -0,0 +1,66 @@
import datetime
from django.db import models
import reversion
from django.contrib.auth.models import Permission, User
TEAM_ACCESS_CHOICES = [
("open", "open"),
("application", "by application"),
("invitation", "by invitation")
]
class Team(models.Model):
slug = models.SlugField(unique=True)
name = models.CharField(max_length=100)
description = models.TextField(blank=True)
access = models.CharField(max_length=20, choices=TEAM_ACCESS_CHOICES)
permissions = models.ManyToManyField(Permission, blank=True)
created = models.DateTimeField(default=datetime.datetime.now, editable=False)
def __unicode__(self):
return self.name
def get_state_for_user(self, user):
try:
return self.memberships.get(user=user).state
except Membership.DoesNotExist:
return None
def applicants(self):
return self.memberships.filter(state="applied")
def invitees(self):
return self.memberships.filter(state="invited")
def members(self):
return self.memberships.filter(state="member")
def managers(self):
return self.memberships.filter(state="manager")
MEMBERSHIP_STATE_CHOICES = [
("applied", "applied"),
("invited", "invited"),
("declined", "declined"),
("rejected", "rejected"),
("member", "member"),
("manager", "manager"),
]
class Membership(models.Model):
user = models.ForeignKey(User, related_name="memberships")
team = models.ForeignKey(Team, related_name="memberships")
state = models.CharField(max_length=20, choices=MEMBERSHIP_STATE_CHOICES)
message = models.TextField(blank=True)
reversion.register(Membership)

View file

View file

@ -0,0 +1,39 @@
from django import template
from symposion.teams.models import Team
register = template.Library()
class AvailableTeamsNode(template.Node):
@classmethod
def handle_token(cls, parser, token):
bits = token.split_contents()
if len(bits) == 3 and bits[1] == "as":
return cls(bits[2])
else:
raise template.TemplateSyntaxError("%r takes 'as var'" % bits[0])
def __init__(self, context_var):
self.context_var = context_var
def render(self, context):
request = context["request"]
teams = []
for team in Team.objects.all():
state = team.get_state_for_user(request.user)
if team.access == "open" and state is None:
teams.append(team)
elif request.user.is_staff and state is None:
teams.append(team)
context[self.context_var] = teams
return u""
@register.tag
def available_teams(parser, token):
"""
{% available_teams as available_teams %}
"""
return AvailableTeamsNode.handle_token(parser, token)

16
symposion/teams/urls.py Normal file
View file

@ -0,0 +1,16 @@
from django.conf.urls.defaults import *
urlpatterns = patterns("symposion.teams.views",
# team specific
url(r"^(?P<slug>[\w\-]+)/$", "team_detail", name="team_detail"),
url(r"^(?P<slug>[\w\-]+)/join/$", "team_join", name="team_join"),
url(r"^(?P<slug>[\w\-]+)/leave/$", "team_leave", name="team_leave"),
url(r"^(?P<slug>[\w\-]+)/apply/$", "team_apply", name="team_apply"),
# membership specific
url(r"^promote/(?P<pk>\d+)/$", "team_promote", name="team_promote"),
url(r"^demote/(?P<pk>\d+)/$", "team_demote", name="team_demote"),
url(r"^accept/(?P<pk>\d+)/$", "team_accept", name="team_accept"),
url(r"^reject/(?P<pk>\d+)/$", "team_reject", name="team_reject"),
)

182
symposion/teams/views.py Normal file
View file

@ -0,0 +1,182 @@
from django.http import Http404
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from symposion.teams.forms import TeamInvitationForm
from symposion.teams.models import Team, Membership
## perm checks
#
# @@@ these can be moved
def can_join(team, user):
state = team.get_state_for_user(user)
if team.access == "open" and state is None:
return True
elif state == "invited":
return True
elif user.is_staff and state is None:
return True
else:
return False
def can_leave(team, user):
state = team.get_state_for_user(user)
if state == "member": # managers can't leave at the moment
return True
else:
return False
def can_apply(team, user):
state = team.get_state_for_user(user)
if team.access == "application" and state is None:
return True
else:
return False
def can_invite(team, user):
state = team.get_state_for_user(user)
if team.access == "invitation":
if state == "manager" or user.is_staff:
return True
return False
## views
@login_required
def team_detail(request, slug):
team = get_object_or_404(Team, slug=slug)
state = team.get_state_for_user(request.user)
if team.access == "invitation" and state is None and not request.user.is_staff:
raise Http404()
if can_invite(team, request.user):
if request.method == "POST":
form = TeamInvitationForm(request.POST, team=team)
if form.is_valid():
form.invite()
messages.success(request, "Invitation created.")
return redirect("team_detail", slug=slug)
else:
form = TeamInvitationForm(team=team)
else:
form = None
return render(request, "teams/team_detail.html", {
"team": team,
"state": state,
"invite_form": form,
"can_join": can_join(team, request.user),
"can_leave": can_leave(team, request.user),
"can_apply": can_apply(team, request.user),
})
@login_required
def team_join(request, slug):
team = get_object_or_404(Team, slug=slug)
state = team.get_state_for_user(request.user)
if team.access == "invitation" and state is None and not request.user.is_staff:
raise Http404()
if can_join(team, request.user) and request.method == "POST":
membership, created = Membership.objects.get_or_create(team=team, user=request.user)
membership.state = "member"
membership.save()
messages.success(request, "Joined team.")
return redirect("team_detail", slug=slug)
else:
return redirect("team_detail", slug=slug)
@login_required
def team_leave(request, slug):
team = get_object_or_404(Team, slug=slug)
state = team.get_state_for_user(request.user)
if team.access == "invitation" and state is None and not request.user.is_staff:
raise Http404()
if can_leave(team, request.user) and request.method == "POST":
membership = Membership.objects.get(team=team, user=request.user)
membership.delete()
messages.success(request, "Left team.")
return redirect("dashboard")
else:
return redirect("team_detail", slug=slug)
@login_required
def team_apply(request, slug):
team = get_object_or_404(Team, slug=slug)
state = team.get_state_for_user(request.user)
if team.access == "invitation" and state is None and not request.user.is_staff:
raise Http404()
if can_apply(team, request.user) and request.method == "POST":
membership, created = Membership.objects.get_or_create(team=team, user=request.user)
membership.state = "applied"
membership.save()
messages.success(request, "Applied to join team.")
return redirect("team_detail", slug=slug)
else:
return redirect("team_detail", slug=slug)
@login_required
def team_promote(request, pk):
if request.method == "POST":
membership = get_object_or_404(Membership, pk=pk)
state = membership.team.get_state_for_user(request.user)
if request.user.is_staff or state == "manager":
if membership.state == "member":
membership.state = "manager"
membership.save()
messages.success(request, "Promoted to manager.")
return redirect("team_detail", slug=membership.team.slug)
@login_required
def team_demote(request, pk):
if request.method == "POST":
membership = get_object_or_404(Membership, pk=pk)
state = membership.team.get_state_for_user(request.user)
if request.user.is_staff or state == "manager":
if membership.state == "manager":
membership.state = "member"
membership.save()
messages.success(request, "Demoted from manager.")
return redirect("team_detail", slug=membership.team.slug)
@login_required
def team_accept(request, pk):
if request.method == "POST":
membership = get_object_or_404(Membership, pk=pk)
state = membership.team.get_state_for_user(request.user)
if request.user.is_staff or state == "manager":
if membership.state == "applied":
membership.state = "member"
membership.save()
messages.success(request, "Accepted application.")
return redirect("team_detail", slug=membership.team.slug)
@login_required
def team_reject(request, pk):
if request.method == "POST":
membership = get_object_or_404(Membership, pk=pk)
state = membership.team.get_state_for_user(request.user)
if request.user.is_staff or state == "manager":
if membership.state == "applied":
membership.state = "rejected"
membership.save()
messages.success(request, "Rejected application.")
return redirect("team_detail", slug=membership.team.slug)

View file

@ -167,6 +167,7 @@ INSTALLED_APPS = [
"symposion.boxes", "symposion.boxes",
"symposion.proposals", "symposion.proposals",
"symposion.speakers", "symposion.speakers",
"symposion.teams",
# project # project
"symposion_project.proposals", "symposion_project.proposals",
@ -193,6 +194,10 @@ ACCOUNT_LOGOUT_REDIRECT_URL = "home"
ACCOUNT_USER_DISPLAY = lambda user: user.email ACCOUNT_USER_DISPLAY = lambda user: user.email
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
# Permissions Backends
"symposion.teams.backends.TeamPermissionsBackend",
# Auth backends
"account.auth_backends.EmailAuthenticationBackend", "account.auth_backends.EmailAuthenticationBackend",
] ]

View file

@ -2,6 +2,7 @@
{% load i18n %} {% load i18n %}
{% load proposal_tags %} {% load proposal_tags %}
{% load teams_tags %}
{% block head_title %}Dashboard{% endblock %} {% block head_title %}Dashboard{% endblock %}
@ -121,4 +122,53 @@
</p> </p>
</div> </div>
</div> </div>
<div class="dashboard-panel">
<div class="dashboard-panel-header">
<i class="icon-group"></i>
<h3>{% trans "Teams" %}</h3>
</div>
<div class="dashboard-panel-content">
{% if user.memberships.exists %}
<h4>Your Teams</h4>
<table class="table table-striped">
{% for membership in user.memberships.all %}
<tr>
<td>
<a href="{% url team_detail membership.team.slug %}">{{ membership.team.name }}</a>
{% if membership.team.description %}<br>{{ membership.team.description }}{% endif %}
</td>
<td>
<span class="label{% if membership.state == 'invited' %} label-info{% endif %}">{{ membership.get_state_display }}</span>
</td>
<td>
{% if membership.state == "manager" or user.is_staff %}
{% if membership.team.applicants %}{{ membership.team.applicants.count }} applicant{{ membership.team.applicants.count|pluralize }}{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% available_teams as available_teams %}
{% if available_teams %}
<h4>Available Teams</h4>
<table class="table table-striped">
{% for team in available_teams %}
<tr>
<td>
<a href="{% url team_detail team.slug %}">{{ team }}</a>
{% if team.description %}<br>{{ team.description }}{% endif %}
</td>
<td>
<span class="label">{{ team.get_access_display }}</span>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
</div>
</div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,103 @@
{% extends "site_base.html" %}
{% load bootstrap_tags %}
{% block head_title %}{{ team.name }}{% endblock %}
{% block body_outer %}
<div class="row">
<div class="span12">
<div class="pull-right">
{% if can_join %}
<form method="post" action="{% url team_join team.slug %}">
{% csrf_token %}
<input type="submit" class="btn btn-primary" value="join">
</form>
{% endif %}
{% if can_leave %}
<form method="post" action="{% url team_leave team.slug %}">
{% csrf_token %}
<input type="submit" class="btn" value="leave">
</form>
{% endif %}
{% if can_apply %}
<form method="post" action="{% url team_apply team.slug %}">
{% csrf_token %}
<input type="submit" class="btn btn-primary" value="apply">
</form>
{% endif %}
</div>
<h1>{{ team.name }}{% if state %} <span class="label">{{ state }}</span>{% endif %}</h1>
{% if team.description %}<p>{{ team.description }}</p>{% endif %}
{% if state == "invited" %}<p>You have been invited to join this team. Click <b>join</b> to the right to accept.</p>{% endif %}
{% if user.is_staff or state == "manager" %}
{% if team.managers %}
<h2>Managers</h2>
<table class="table table-striped">
{% for membership in team.managers %}
<tr>
<td>{{ membership.user.email }}{% if user == membership.user %} <span class="label label-info">you</span>{% endif %}</td>
<td>
<form style="margin: 0;" method="post" action="{% url team_demote membership.pk %}">{% csrf_token %}<button type="submit" class="btn btn-mini">demote</button></form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if team.members %}
<h2>Team Members</h2>
<table class="table table-striped">
{% for membership in team.members %}
<tr>
<td>{{ membership.user.email }}{% if user == membership.user %} <span class="label label-info">you</span>{% endif %}</td>
<td>
<form style="margin: 0;" method="post" action="{% url team_promote membership.pk %}">{% csrf_token %}<button type="submit" class="btn btn-mini">promote</button></form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if team.applicants and team.access == "application" %}
<h2>Applicants</h2>
<table class="table table-striped">
{% for membership in team.applicants %}
<tr>
<td>{{ membership.user.email }}</td>
<td>
<form style="margin: 0; float: left;" method="post" action="{% url team_accept membership.pk %}">{% csrf_token %}<button type="submit" class="btn btn-mini">accept</button></form>
<form style="margin: 0; float: left;" method="post" action="{% url team_reject membership.pk %}">{% csrf_token %}<button type="submit" class="btn btn-mini">reject</button></form>
</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if team.invitees %}
<h2>Invitees</h2>
<table class="table table-striped">
{% for membership in team.invitees %}
<tr>
<td>{{ membership.user.email }}</td>
</tr>
{% endfor %}
</table>
{% endif %}
{% if invite_form %}
<form method="POST" action="" class="form-horizontal">
{% csrf_token %}
<legend>Invite User to Team</legend>
{{ invite_form|as_bootstrap }}
<div class="form-actions">
<input class="btn btn-primary" type="submit" value="Invite" />
</div>
</form>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -28,6 +28,7 @@ urlpatterns = patterns("",
url(r"^proposals/", include("symposion.proposals.urls")), url(r"^proposals/", include("symposion.proposals.urls")),
url(r"^sponsors/", include("symposion.sponsorship.urls")), url(r"^sponsors/", include("symposion.sponsorship.urls")),
url(r"^boxes/", include("symposion.boxes.urls")), url(r"^boxes/", include("symposion.boxes.urls")),
url(r"^teams/", include("symposion.teams.urls")),
url(r"^markitup/", include("markitup.urls")), url(r"^markitup/", include("markitup.urls")),
url(r"^", include("symposion.cms.urls")), url(r"^", include("symposion.cms.urls")),