changeset 94:5c90eef9f2c1

- Import user->group membership - Clean-up duplicates and dangling membership - Document the User/Group inheritence (compared to using Profiles) - Introduce get_obsolute_url(...) functions in models - Add templates for users, groups and licenses, with links from one another - Filter users by status in the admin interface - Display a 404 when accessing 'my' with a User that doesn't have an ExtendedUser - Simplify login template to inherit error reporting
author Sylvain Beucler <beuc@beuc.net>
date Wed, 29 Jul 2009 14:45:09 +0200
parents 149fcd78909f
children 801166bc508a
files migrate_old_savane.sql src/savane/my/urls.py src/savane/my/views.py src/savane/svmain/admin.py src/savane/svmain/models.py src/savane/svmain/urls.py template/registration/login.html template/svmain/extendedgroup_detail.html template/svmain/extendedgroup_list.html template/svmain/extendeduser_detail.html template/svmain/license_detail.html template/svmain/license_list.html
diffstat 12 files changed, 323 insertions(+), 14 deletions(-) [+]
line wrap: on
line diff
--- a/migrate_old_savane.sql
+++ b/migrate_old_savane.sql
@@ -270,3 +270,48 @@
       url_extralink_documentation
     FROM savane_old.groups LEFT JOIN savane.svmain_license ON savane_old.groups.license = savane.svmain_license.slug
     WHERE savane_old.groups.group_id != 100;
+
+-- Import users<->groups relationships
+-- Get rid of duplicates (long: several minutes):
+DELETE FROM savane_old.user_group
+  WHERE user_group_id IN (
+    SELECT A.user_group_id
+      FROM savane_old.user_group A, savane_old.user_group B
+      WHERE A.user_id = B.user_id AND A.group_id = B.group_id
+      GROUP BY A.user_id, A.group_id HAVING count(*) > 1
+  );
+-- Actual import
+INSERT INTO auth_user_groups
+    (user_id, group_id)
+  SELECT user_id, group_id
+    FROM savane_old.user_group;
+INSERT INTO svmain_membership
+    (user_id, group_id, admin_flags, onduty)
+  SELECT user_id, group_id, admin_flags, onduty
+    FROM savane_old.user_group;
+-- Get rid of ghost relationships (deleted group)
+DELETE FROM svmain_membership
+  WHERE group_id IN (
+    SELECT group_id FROM (
+      SELECT group_id
+        FROM svmain_membership
+          LEFT JOIN svmain_extendedgroup ON svmain_membership.group_id = svmain_extendedgroup.group_ptr_id
+        WHERE group_ptr_id IS NULL
+      ) AS temp
+    );
+-- Get rid of ghost relationships (deleted user)
+DELETE FROM svmain_membership WHERE user_id IN (
+  SELECT user_id FROM (
+    SELECT user_id
+      FROM svmain_membership
+        LEFT JOIN svmain_extendeduser ON svmain_membership.user_id = svmain_extendeduser.user_ptr_id
+      WHERE user_ptr_id IS NULL
+    ) AS temp
+  );
+-- Set members of 'administration' as superusers
+-- TODO: get the supergroup name from the old Savane configuration
+UPDATE auth_user SET is_staff=1, is_superuser=1
+  WHERE id IN (
+    SELECT user_id
+    FROM auth_user_groups JOIN auth_group ON auth_user_groups.group_id = auth_group.id
+    WHERE auth_group.name='administration');
--- a/src/savane/my/urls.py
+++ b/src/savane/my/urls.py
@@ -31,7 +31,6 @@
     the current user"""
     request = args[0]
     user = request.user
-    print kwargs
     kwargs['queryset'] = kwargs['queryset'].filter(user=user.id)
     return f(*args, **kwargs)
 
@@ -53,5 +52,5 @@
   url('^conf/ssh_gpg$', views.sv_ssh_gpg),
   url(r'^groups/$', object_list__only_mine,
       { 'queryset' : svmain_models.ExtendedGroup.objects.all() },
-      name='savane.my.generic.group_list'),
+      name='savane.my.group_list'),
 )
--- a/src/savane/my/views.py
+++ b/src/savane/my/views.py
@@ -84,7 +84,7 @@
                                context_instance=RequestContext(request))
 @login_required()
 def sv_ssh_gpg( request ):
-    eu = ExtendedUser.objects.get(pk=request.user.pk)
+    eu = get_object_or_404(ExtendedUser, pk=request.user.pk)
 
     error_msg = None
     success_msg = None
--- a/src/savane/svmain/admin.py
+++ b/src/savane/svmain/admin.py
@@ -27,7 +27,7 @@
                      'people_view_skills', 'email_hide', 'timezone', 'theme',)}),
         )
     list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
-    list_filter = ('is_staff', 'is_superuser')
+    list_filter = ('is_staff', 'is_superuser', 'status')
     search_fields = ('username', 'first_name', 'last_name', 'email')
     ordering = ('username',)
     filter_horizontal = ('user_permissions',)
--- a/src/savane/svmain/models.py
+++ b/src/savane/svmain/models.py
@@ -19,6 +19,42 @@
 # 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/>.
 
+"""
+User/group extra attributes
+
+This may look like reinventing the User.get_profile() that comes with
+Django;
+http://www.b-list.org/weblog/2006/jun/06/django-tips-extending-user-model/
+http://mirobetm.blogspot.com/2007/04/en-django-extending-user_2281.html
+
+However profiles were mainly useful in Django < 1.0 where you couldn't
+subclass User as we do.
+
+Profiles also have a few drawbacks, namely they are site-specific,
+which means you cannot have multiple applications have different
+profiles in the same website, while with subclassing you only need to
+user different class names (to avoid parent->child fieldname clash).
+
+Moreover splitting the information in two different models can be
+cumbersome when using ModelForms.
+
+Subclassing drawback: there's apparently a technique to use a
+vhost-based profile class (with django.contrib.site and multiples
+settings.py). But it's not useful for Savane IMHO.
+
+In addition, it seems impossible to convert an existing User to a
+derived class from Python (this can be done through DB but that's
+ugly). This apparently prevents auto-creating our derived class when a
+new User is directly created (and sends a post_save signal).
+
+Profiles vs. inheritance is also described at
+http://scottbarnham.com/blog/2008/08/21/extending-the-django-user-model-with-inheritance/
+
+Note that Scott's authentication backend has the same issue than
+profiles: only one profile class can be used on a single website, so
+we don't use it.
+"""
+
 from django.db import models
 from django.contrib.auth import models as auth_models
 
@@ -59,10 +95,36 @@
     timezone = models.CharField(max_length=192, blank=True)
     theme = models.CharField(max_length=45, blank=True)
 
+    # Non-field link to extended groups
+    extendedgroup_set = models.ManyToManyField('ExtendedGroup', through='Membership')
 
     # Inherit specialized models.Manager with convenience functions
     objects = auth_models.UserManager()
 
+    @models.permalink
+    def get_absolute_url(self):
+        return ('savane.svmain.user_detail', [self.username])
+
+# FIXME
+# Let's make sure extendeduser is always created, even if somehow a
+# normal User is created (from the admin interface, e.g.)
+# This currently fails, and this doesn't support creating a model if
+# another apps creates a derived class.
+#from django.contrib.auth.models import User
+#from django.db.models.signals import post_save
+#def user_post_save_handler(sender, **kwargs):
+#    """
+#    Create an ExtendedUser when a User is directly created
+#
+#    Called when User is created
+#    (but not called when a ExtendedUser is created)
+#    """
+#    u = kwargs['instance']
+#    if kwargs['created'] == True:
+#        eu = ExtendedUser(user_ptr=u)
+#        eu.save()  # ERROR
+#post_save.connect(user_post_save_handler, sender=User)
+
 class License(models.Model):
     """
     Main license used by a project
@@ -77,6 +139,10 @@
     def __unicode__(self):
         return self.slug + ": " + self.name
 
+    @models.permalink
+    def get_absolute_url(self):
+        return ('savane.svmain.license_detail', [self.slug])
+
     class Meta:
         ordering = ['slug']
 
@@ -382,6 +448,50 @@
     def __unicode__(self):
         return self.name
 
+    @models.permalink
+    def get_absolute_url(self):
+        return ('savane.svmain.group_detail', [self.name])
+
     class Meta:
         ordering = ['name']
 
+
+class Membership(models.Model):
+    """
+    Extra attributes about a User<->Group relationship
+    (e.g. "is the user an admin?")
+    """
+    user = models.ForeignKey(ExtendedUser)
+    group = models.ForeignKey(ExtendedGroup)
+
+    admin_flags_CHOICES = (
+        ('A', 'Admin'),
+        # IMHO we need to put 'P' in a separate table, like 'pending
+        # membership', otherwise it's too easy to make mistakes
+        ('P', 'Pending moderation'),
+        ('SQD', 'Squad'), # FIXME: I dislike squad=user
+        )
+    admin_flags = models.CharField(max_length=3, choices=admin_flags_CHOICES,
+      blank=True, help_text="membership properties")
+    onduty = models.BooleanField(default=True,
+      help_text="Untick to hide emeritous members from the project page")
+
+    # TODO: split news params
+    #news_flags int(11) default NULL
+    
+    # Trackers-related
+    #privacy_flags = models.BooleanField(default=True)
+    #bugs_flags int(11) default NULL
+    #task_flags int(11) default NULL
+    #patch_flags int(11) default NULL
+    #support_flags int(11) default NULL
+    #cookbook_flags int(11) default NULL
+
+    # Deprecated
+    #forum_flags int(11) default NULL
+
+    def __unicode__(self):
+        return "[%s is a member of %s]" % (self.user.username, self.group.name)
+
+    class Meta:
+        unique_together = (('user', 'group'),)
--- a/src/savane/svmain/urls.py
+++ b/src/savane/svmain/urls.py
@@ -19,6 +19,8 @@
 
 from django.conf.urls.defaults import *
 
+from savane.svmain import models as svmain_models
+
 urlpatterns = patterns ('',
   url(r'^$', 'django.views.generic.simple.direct_to_template',
       { 'template' : 'index.html',
@@ -27,4 +29,24 @@
   url(r'^contact$', 'django.views.generic.simple.direct_to_template',
       { 'template' : 'contact.html' },
       name='contact'),
+
+  # TODO: not sure about the views naming convention - all this
+  # "models in 'svmain', views in 'my'" is getting messy, probably a
+  # mistake from me (Beuc) :P
+  url(r'^projects/(?P<slug>[-\w]+)$', 'django.views.generic.list_detail.object_detail',
+      { 'queryset' : svmain_models.ExtendedGroup.objects.all(),
+        'slug_field' : 'name' },
+      name='savane.svmain.group_detail'),
+
+  url(r'^users/(?P<slug>[-\w]+)$', 'django.views.generic.list_detail.object_detail',
+      { 'queryset' : svmain_models.ExtendedUser.objects.all(),
+        'slug_field' : 'username' },
+      name='savane.svmain.user_detail'),
+
+  url(r'^license/$', 'django.views.generic.list_detail.object_list',
+      { 'queryset' : svmain_models.License.objects.all(), },
+      name='savane.svmain.license_list'),
+  url(r'^license/(?P<slug>[-\w]+)$', 'django.views.generic.list_detail.object_detail',
+      { 'queryset' : svmain_models.License.objects.all(), },
+      name='savane.svmain.license_detail'),
 )
--- a/template/registration/login.html
+++ b/template/registration/login.html
@@ -2,14 +2,12 @@
 {% extends "base.html" %}
 
 {% block content %}
-Please login:
+Login:
+
 <form action="{% url django.contrib.auth.views.login %}" method="post">
-<dl>
-  <dt>Login Name:</dt>
-  <dd><input type="text" name="username" value="" size="12" /></dd>
-  <dt>Password:</dt>
-  <dd><input type="password" name="password" size="12" /></dd>
-  <dd><input type="submit" name="login" value="Login" /></dd>
-</dl>
+{{form.as_p}}
+<input type="submit" name="login" value="Login" />
+</table>
 </form>
+
 {% endblock %}
new file mode 100644
--- /dev/null
+++ b/template/svmain/extendedgroup_detail.html
@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+<p>
+Name: {{object.name}}<br />
+License: <a href="{{object.license.get_absolute_url}}">{{object.license.name}}</a><br />
+Development status: {{object.devel_status}}<br />
+</p>
+
+<p>Members:</p>
+
+{% if object.user_set.all %}
+<ul>
+  {% for user in object.user_set.all %}
+  <li><a href="{{ user.extendeduser.get_absolute_url }}">{{ user.username }}</a></li>
+  {% endfor %}
+  </ul>
+{% else %}
+  No members!
+{% endif %}
+
+{% endblock %}
+
+{% comment %}
+Local Variables: **
+mode: django-html **
+tab-width: 4 **
+indent-tabs-mode: nil **
+End: **
+{% endcomment %}
--- a/template/svmain/extendedgroup_list.html
+++ b/template/svmain/extendedgroup_list.html
@@ -5,11 +5,11 @@
 {% if object_list %}
     <ul>
     {% for object in object_list %}
-        <li><a href="{{object.id}}">{{ object }}</a></li>
+        <li><a href="{{ object.get_absolute_url }}">{{ object }}</a></li>
     {% endfor %}
     </ul>
 {% else %}
-    <p>You are not part of any group.</p>
+    <p>No group.</p>
 {% endif %}
 
 {% endblock %}
new file mode 100644
--- /dev/null
+++ b/template/svmain/extendeduser_detail.html
@@ -0,0 +1,59 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+<p>Name: {{object.username}}</p>
+
+<p>Is a member of (using Django user.groups):</p>
+
+{% if object.groups.all %}
+<ul>
+  {% for group in object.groups.all %}
+  <li>
+    <a href="{{ group.extendedgroup.get_absolute_url }}">{{ group.name }}</a>
+  </li>
+  {% endfor %}
+  </ul>
+{% else %}
+  Not part of any group yet
+{% endif %}
+
+<p>Is a member of (using Membership):</p>
+
+{% if object.membership_set.all %}
+<ul>
+  {% for membership in object.membership_set.all %}
+  <li>
+    <a href="{{ membership.group.get_absolute_url }}">{{ membership.group.name }}</a> {{ membership.admin_flags}}
+  </li>
+  {% endfor %}
+  </ul>
+{% else %}
+  Not part of any group yet
+{% endif %}
+
+
+<p>Is a member of (using Membership + direct field):</p>
+
+{% if object.extendedgroup_set.all %}
+<ul>
+  {% for group in object.extendedgroup_set.all %}
+  <li>
+    <a href="{{ group.get_absolute_url }}">{{ group.name }}</a>
+  </li>
+  {% endfor %}
+  </ul>
+{% else %}
+  Not part of any group yet
+{% endif %}
+
+
+{% endblock %}
+
+{% comment %}
+Local Variables: **
+mode: django-html **
+tab-width: 4 **
+indent-tabs-mode: nil **
+End: **
+{% endcomment %}
new file mode 100644
--- /dev/null
+++ b/template/svmain/license_detail.html
@@ -0,0 +1,28 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+<p><a href="{% url savane.svmain.license_list %}">License list</a></p>
+
+<p>
+Name: {{object.slug}}<br />
+URL: <a href="{{object.license.url}}">{{object.url}}</a><br />
+</p>
+
+<p>Projects that use it:</p>
+
+{{ object.extendedgroup_set.count }} project(s) use this license<br />
+
+{% for eg in object.extendedgroup_set.all %}
+<a href="{{eg.get_absolute_url}}">{{eg.name}}</a>{% if forloop.last %}{%else%},{% endif %}
+{% endfor %}
+
+{% endblock %}
+
+{% comment %}
+Local Variables: **
+mode: django-html **
+tab-width: 4 **
+indent-tabs-mode: nil **
+End: **
+{% endcomment %}
new file mode 100644
--- /dev/null
+++ b/template/svmain/license_list.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+
+{% block content %}
+
+{% for object in object_list %}
+<a href="{{object.get_absolute_url}}">{{object.name}}</a><br />
+{% endfor %}
+
+{% endblock %}
+
+{% comment %}
+Local Variables: **
+mode: django-html **
+tab-width: 4 **
+indent-tabs-mode: nil **
+End: **
+{% endcomment %}