changeset 186:6bc5e698e9c4

Interface to manage project members (TODO: search for members)
author Sylvain Beucler <beuc@beuc.net>
date Sun, 25 Jul 2010 12:47:51 +0200
parents 3d1a07772d4d
children 95a750a73174
files savane/django_utils.py savane/middleware/exception.py savane/my/urls.py savane/perms.py savane/svmain/models.py savane/svmain/urls.py savane/svmain/views.py settings_default.py static_media/savane/css/Savannah.css templates/error.html templates/svmain/group_admin_members.html
diffstat 11 files changed, 242 insertions(+), 23 deletions(-) [+]
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/savane/django_utils.py
@@ -0,0 +1,47 @@
+# Batch-decorator for urlpatterns
+# http://www.djangosnippets.org/snippets/532/
+# Author: miracle2k (Jan 1, 2008)
+# 
+# I hate legal-speak as much as anybody, but on a site which is geared
+# toward sharing code there has to be at least a little bit of it, so
+# here goes:
+# 
+# By creating an account here you agree to three things:
+# 
+#   1. That you will only post code which you wrote yourself and that
+#   you have the legal right to release under these terms.
+# 
+#   2. That you grant any third party who sees the code you post a
+#   royalty-free, non-exclusive license to copy and distribute that
+#   code and to make and distribute derivative works based on that
+#   code. You may include license terms in snippets you post, if you
+#   wish to use a particular license (such as the BSD license or GNU
+#   GPL), but that license must permit royalty-free copying,
+#   distribution and modification of the code to which it is applied.
+# 
+#   3. That if you post code of which you are not the author or for
+#   which you do not have the legal right to distribute according to
+#   these terms, you will indemnify and hold harmless the operators of
+#   this site and any third parties who are exposed to liability as a
+#   result of your actions.
+#
+# If you can't legally agree to these terms, or don't want to, you
+# cannot create an account here.
+
+from django.core.urlresolvers import RegexURLPattern
+from django.conf.urls.defaults import patterns
+class DecoratedURLPattern(RegexURLPattern):
+    def resolve(self, *args, **kwargs):
+        result = RegexURLPattern.resolve(self, *args, **kwargs)
+        if result:
+            result = list(result)
+            result[0] = self._decorate_with(result[0])
+        return result
+def decorated_patterns(prefix, func, *args):
+    result = patterns(prefix, *args)
+    if func:
+        for p in result:
+            if isinstance(p, RegexURLPattern):
+                p.__class__ = DecoratedURLPattern
+                p._decorate_with = func
+    return result
new file mode 100644
--- /dev/null
+++ b/savane/middleware/exception.py
@@ -0,0 +1,34 @@
+# Catch app-specific exception and display it to the user
+# Copyright (C) 2009, 2010  Sylvain Beucler
+#
+# This file is part of Savane.
+# 
+# Savane is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+# 
+# Savane is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+
+class HttpAppException(Exception):
+    pass
+
+class HttpCatchAppExceptionMiddleware(object):
+    def process_exception(self, request, exception):
+        """
+        Only catch our HttpAppException (and derivate classes)
+        """
+        if isinstance(exception, HttpAppException):
+            return render_to_response('error.html',
+                                      { 'error' : exception.message },
+                                      context_instance=RequestContext(request))
+        return None
--- a/savane/my/urls.py
+++ b/savane/my/urls.py
@@ -25,27 +25,11 @@
 import savane.svmain.models as svmain_models
 import django.contrib.auth.models as auth_models
 from savane.my.filters import *
+from savane.django_utils import decorated_patterns
 
-# Batch-decorator for urlpatterns
-# http://www.djangosnippets.org/snippets/532/
-from django.core.urlresolvers import RegexURLPattern
-class DecoratedURLPattern(RegexURLPattern):
-    def resolve(self, *args, **kwargs):
-        result = RegexURLPattern.resolve(self, *args, **kwargs)
-        if result:
-            result = list(result)
-            result[0] = self._decorate_with(result[0])
-        return result
-def decorated_patterns(prefix, func, *args):
-    result = patterns(prefix, *args)
-    if func:
-        for p in result:
-            if isinstance(p, RegexURLPattern):
-                p.__class__ = DecoratedURLPattern
-                p._decorate_with = func
-    return result
+urlpatterns = patterns ('',)
 
-urlpatterns = decorated_patterns ('', login_required,
+urlpatterns += decorated_patterns ('', login_required,
   url(r'^$', direct_to_template,
       { 'template' : 'my/index.html',
         'extra_context' : { 'title' : 'My account', }, },
new file mode 100644
--- /dev/null
+++ b/savane/perms.py
@@ -0,0 +1,43 @@
+# Permission restrictions to use as view decorators
+# Copyright (C) 2010  Sylvain Beucler
+#
+# This file is part of Savane.
+# 
+# Savane is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+# 
+# Savane is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import django.contrib.auth.models as auth_models
+from django.shortcuts import get_object_or_404
+from savane.middleware.exception import HttpAppException
+import savane.svmain.models as svmain_models
+
+def is_member(user, group):
+    return group.user_set.filter(pk=user.pk).count() > 0
+
+def is_admin(user, group):
+    return (is_member(user, group)
+            and svmain_models.Membership.objects
+            .filter(user=user, group=group, admin_flags='A'))
+
+def only_project_admin(f, error_msg="Permission Denied"):
+    """
+    Decorator to keep non-members out of project administration
+    screens.  Identifies the current group using the 'slug' keyword
+    parameter.
+    """
+    def _f(request, *args, **kwargs):
+        group = get_object_or_404(auth_models.Group, name=kwargs['slug'])
+        if request.user.is_anonymous() or not is_admin(request.user, group):
+            raise HttpAppException(error_msg)
+        return f(request, *args, **kwargs)
+    return _f
--- a/savane/svmain/models.py
+++ b/savane/svmain/models.py
@@ -533,6 +533,7 @@
       blank=True, help_text="membership properties")
     onduty = models.BooleanField(default=True,
       help_text="Untick to hide emeritous members from the project page")
+    since = models.DateField(blank=True, null=True)
 
     # TODO: split news params
     #news_flags int(11) default NULL
--- a/savane/svmain/urls.py
+++ b/savane/svmain/urls.py
@@ -19,11 +19,14 @@
 
 from django.conf.urls.defaults import *
 from django.views.generic.list_detail import object_list, object_detail
+from django.contrib.auth.decorators import login_required
 
 import savane.svmain.models as svmain_models
 import django.contrib.auth.models as auth_models
 import views
 from savane.filters import search
+from savane.perms import only_project_admin
+from savane.django_utils import decorated_patterns
 
 urlpatterns = patterns ('',)
 
@@ -82,13 +85,17 @@
   url(r'^pr/(?P<slug>[-\w]+)/$', views.group_redir),
   url(r'^projects/(?P<slug>[-\w]+)/$', views.group_redir),
   url(r'^p/(?P<slug>[-\w]+)/join/$', views.group_join),
+)
+urlpatterns += decorated_patterns ('', only_project_admin,
   url(r'^p/(?P<slug>[-\w]+)/admin/$', views.group_admin,
       { 'extra_context' : { 'title' : 'Administration Summary' }, },
       name='savane.svmain.group_admin'),
   url(r'^p/(?P<slug>[-\w]+)/admin/members/$', views.group_admin_members,
       { 'extra_context' : { 'title' : 'Administration Summary: Manage Members' }, },
       name='savane.svmain.group_admin_members'),
+)
 
+urlpatterns += patterns ('',
   url(r'^license/$', 'django.views.generic.list_detail.object_list',
       { 'queryset' : svmain_models.License.objects.all(),
         'extra_context' : { 'title' : 'License list' }, },
--- a/savane/svmain/views.py
+++ b/savane/svmain/views.py
@@ -55,11 +55,56 @@
 @render_to('svmain/group_admin_members.html', mimetype=None)
 def group_admin_members(request, slug, extra_context={}):
     group = get_object_or_404(auth_models.Group, name=slug)
-    members = group.user_set.all()
+
+    # If using a non-Savane groups base, prepare membership metadata
+    user_pks = svmain_models.Membership.objects.filter(group=group).values_list('user__pk', flat=True)
+    missing_members = group.user_set.exclude(pk__in=user_pks)
+    for member in missing_members:
+        svmain_models.Membership(user=member, group=group, admin_flags='A').save()
+
+    # If a membership does not have a matching User<->Group relationship, remove it
+    user_pks = group.user_set.values_list('pk', flat=True)
+    invalid_memberships = svmain_models.Membership.objects.exclude(user__in=user_pks).exclude(admin_flags='P')
+    invalid_memberships.delete()
+
+
+    memberships = svmain_models.Membership.objects.filter(group=group).exclude(admin_flags='P')
+    pending_memberships = svmain_models.Membership.objects.filter(group=group, admin_flags='P')
+
+
+    if request.method == 'POST':
+        for membership in memberships:
+            if request.user != membership.user: # don't unadmin or remove myself
+                # admin / unadmin 
+                if request.POST.get('admin_%d' % membership.pk, None):
+                    if membership.admin_flags != 'A':
+                        membership.admin_flags = 'A'
+                        membership.save()
+                        messages.success(request, "permissions of %s updated." % membership.user)
+                else:
+                    if membership.admin_flags != '':
+                        membership.admin_flags = ''
+                        membership.save()
+                        messages.success(request, "permissions of %s updated." % membership.user)
+                # remove members
+                if request.POST.get('remove_%d' % membership.pk, None):
+                    group.user_set.remove(membership.user)
+                    membership.delete()
+                    messages.success(request, "User %s deleted from the project." % membership.user)
+        # approve pending membership
+        for membership in pending_memberships:
+            if request.POST.get('approve_%d' % membership.pk, None):
+                group.user_set.add(membership.user)
+                membership.admin_flags = ''
+                membership.save()
+                messages.success(request, "User %s added to the project." % membership.user)
+        return HttpResponseRedirect('')  # reload
+
 
     context = {
         'group' : group,
-        'members' : members,
+        'memberships' : memberships,
+        'pending_memberships' : pending_memberships,
         }
     context.update(extra_context)
     return context
--- a/settings_default.py
+++ b/settings_default.py
@@ -82,6 +82,7 @@
     'django.contrib.messages.middleware.MessageMiddleware',
 
     'savane.middleware.debug.DebugFooter',
+    'savane.middleware.exception.HttpCatchAppExceptionMiddleware',
 )
 
 ROOT_URLCONF = 'urls'
--- a/static_media/savane/css/Savannah.css
+++ b/static_media/savane/css/Savannah.css
@@ -2,7 +2,7 @@
  *
  * Copyright (C) 2002-2006  Mathieu Roy
  * Copyright (C) 2005  Stéphane Urbanovski
- * Copyright (C) 2005  Sylvain Beucler
+ * Copyright (C) 2005, 2010  Sylvain Beucler
  *
  * This file is part of Savane.
  *
--- a/templates/error.html
+++ b/templates/error.html
@@ -1,5 +1,19 @@
 {% extends "base.html" %}
 
+{% block title %}
+Error
+{% endblock %}
+
+{% block top %}
+{% endblock %}
+
 {% block content %}
-<p style='color: red'>{{error}}</p>
+<div class="main"><a name="top"></a>
+<div id="feedback" class="feedbackerror">
+  <span class="feedbackerrortitle">
+    <img src="{{STATIC_MEDIA_URL}}savane/images/common/bool1/wrong.orig.png" class="feedbackimage" alt="" />
+    Error:
+  </span><br/>
+  {{error}}
+</div>
 {% endblock content %}
--- a/templates/svmain/group_admin_members.html
+++ b/templates/svmain/group_admin_members.html
@@ -14,6 +14,49 @@
 
 {% block content %}
 
+<p>Members:</p>
+<form action="" method="POST">{% csrf_token %}
+<table>
+<tr><th>User</th><th>Admin</th><th>Remove?</th></tr>
+{% for membership in memberships %}
+<tr>
+  <td>{{ membership.user.get_full_name }} &lt;{{ membership.user.username }}&gt;</td>
+  <td>
+    {% ifequal request.user membership.user %}
+    <em>{% trans "You are Admin" %}</em>
+    {% else %}
+    <input type="checkbox" name="admin_{{membership.pk}}" {% ifequal membership.admin_flags "A" %}checked="checked"{% endifequal %}/>
+    {% endifequal %}
+  </td>
+  <td>
+    {% ifequal request.user membership.user %}
+    -
+    {% else %}    <input type="checkbox" name="remove_{{membership.pk}}" />
+    {% endifequal %}
+  </td>
+</tr>
+{% endfor %}
+</table>
+
+{% if pending_memberships %}
+  <p>Requests for inclusion:</p>
+  <table>
+  <tr><th>User</th><th>Approve</th></tr>
+  {% for membership in pending_memberships %}
+  <tr>
+    <td>{{ membership.user.get_full_name }} &lt;{{ membership.user.username }}&gt;</td>
+    <td><input type="checkbox" name="approve_{{membership.pk}}" /></td>
+  </tr>
+  {% endfor %}
+  </table>
+{% else %}
+  <p><em>{% trans "No requests for inclusion" %}</em></p>
+{% endif %}
+
+<p><input type="submit" value="OK" /></p>
+</form>
+
+<p><a href="add/">{% trans "Add users to group" %}</a></p>
 
 {% endblock %}