Skip to content
This repository has been archived by the owner on Jan 19, 2021. It is now read-only.

Commit

Permalink
Added skills tag to userprofile
Browse files Browse the repository at this point in the history
This is a next step that wasn't implemented in the first implementation
of bug 728877. This creates a new skills model. This inherits from a
GroupBase model and so does Group. This allows for more reusable code.
  • Loading branch information
tallowen committed Mar 30, 2012
1 parent 9d7b03f commit b5819c2
Show file tree
Hide file tree
Showing 16 changed files with 425 additions and 122 deletions.
1 change: 1 addition & 0 deletions apps/common/backends.py
Expand Up @@ -20,6 +20,7 @@ class MozilliansBrowserID(BrowserIDBackend):
assertion. This is dangerous. Don't use authenticated_email unless you've
just verified somebody.
"""
supports_inactive_user = False

def authenticate(self, assertion=None, audience=None, authenticated_email=None):
if authenticated_email:
Expand Down
13 changes: 11 additions & 2 deletions apps/groups/cron.py
Expand Up @@ -5,22 +5,31 @@
import commonware.log
import cronjobs

from groups.models import AUTO_COMPLETE_COUNT, Group
from groups.models import AUTO_COMPLETE_COUNT, Group, Skill


log = commonware.log.getLogger('m.cron')


@cronjobs.register
def assign_autocomplete_to_groups():
"""Hourly job to assign autocomplete status to popular Mozillian groups."""
"""
Hourly job to assign autocomplete status to Mozillian popular groups and
skills.
"""
# Only assign status to non-system groups.
# TODO: add stats.d timer here
for g in (Group.objects.filter(always_auto_complete=False, system=False)
.annotate(count=Count('userprofile'))):
g.auto_complete = g.count > AUTO_COMPLETE_COUNT
g.save()

# Assign appropriate status to skills
for g in (Skill.objects.filter(always_auto_complete=False)
.annotate(count=Count('userprofile'))):
g.auto_complete = g.count > AUTO_COMPLETE_COUNT
g.save()


@cronjobs.register
def assign_staff_to_early_users():
Expand Down
5 changes: 3 additions & 2 deletions apps/groups/helpers.py
@@ -1,8 +1,9 @@
import jinja2
from jingo import register


@register.function
def stringify_groups(groups):
"""Change a list of Group objects into a space-delimited string."""
"""
Change a list of Group (or skills) objects into a space-delimited string.
"""
return u','.join([group.name for group in groups])
46 changes: 46 additions & 0 deletions apps/groups/migrations/0003_auto__add_skill.py
@@ -0,0 +1,46 @@
# encoding: utf-8
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models

class Migration(SchemaMigration):

def forwards(self, orm):

# Adding model 'Skill'
db.create_table('groups_skill', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=50, db_index=True)),
('auto_complete', self.gf('django.db.models.fields.BooleanField')(default=False, db_index=True)),
('always_auto_complete', self.gf('django.db.models.fields.BooleanField')(default=False)),
))
db.send_create_signal('groups', ['Skill'])


def backwards(self, orm):

# Deleting model 'Skill'
db.delete_table('groups_skill')


models = {
'groups.group': {
'Meta': {'object_name': 'Group', 'db_table': "'group'"},
'always_auto_complete': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'auto_complete': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
'system': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
'url': ('django.db.models.fields.SlugField', [], {'max_length': '50', 'db_index': 'True'})
},
'groups.skill': {
'Meta': {'object_name': 'Skill'},
'always_auto_complete': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'auto_complete': ('django.db.models.fields.BooleanField', [], {'default': 'False', 'db_index': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'})
}
}

complete_apps = ['groups']
51 changes: 36 additions & 15 deletions apps/groups/models.py
Expand Up @@ -7,32 +7,52 @@
AUTO_COMPLETE_COUNT = 10


class Group(models.Model):
"""A Group is an arbitrary name attached to one or more UserProfiles.
Each Group has a canonical name, but also a list of related names
(usually alternative spellings, misspellings, or related terms -- e.g.
"Add-ons" might have "addons" and "extensions" as related terms.).
In this vein, groups should also be case-insensitive, but presented in
their canonical case.
class GroupBase(models.Model):
"""
Base model for Skills and Groups
Users can add their own groups to the system, but certain Groups may be
deemed more important by admins.
Think of tags on a user profile.
"""
name = models.CharField(db_index=True, max_length=50, unique=True)
url = models.SlugField()

# If this is true, this Group will appear in the autocomplete list.
# If this is true, this Group/Skill will appear in the autocomplete list.
auto_complete = models.BooleanField(db_index=True, default=False)
always_auto_complete = models.BooleanField(default=False)
system = models.BooleanField(db_index=True, default=False)

class Meta:
db_table = 'group'
abstract = True

@classmethod
def search(cls, query, auto_complete_only=True):
if query:
return list(
cls.objects.filter(
name__istartswith=query,
auto_complete=auto_complete_only
).values_list('name', flat=True)
)
return []

def __unicode__(self):
"""Return the name of this group, unless it doesn't have one yet."""
return getattr(self, 'name', u'Unnamed Group')
return getattr(self, 'name', u'Unnamed')


class Group(GroupBase):
url = models.SlugField()
system = models.BooleanField(db_index=True, default=False)

class Meta:
db_table = 'group'


class Skill(GroupBase):
"""
Model to hold skill tags
Like groups but without system prefs or pages/urls
"""
pass


@receiver(models.signals.pre_save, sender=Group)
Expand All @@ -42,6 +62,7 @@ def _create_url_slug(sender, instance, raw, using, **kwargs):
instance.url = slugify(instance.name.lower())


@receiver(models.signals.pre_save, sender=Skill)
@receiver(models.signals.pre_save, sender=Group)
def _lowercase_name(sender, instance, raw, using, **kwargs):
"""Convert any group's name to lowercase before it's saved."""
Expand Down
30 changes: 29 additions & 1 deletion apps/groups/tests.py → apps/groups/tests/test_groups.py
@@ -1,12 +1,15 @@
import json

from django.contrib.auth.models import User

from funfactory.urlresolvers import reverse
from nose.tools import eq_
from pyquery import PyQuery as pq

import common.tests
from groups.cron import assign_autocomplete_to_groups
from groups.helpers import stringify_groups
from groups.models import Group
from groups.models import AUTO_COMPLETE_COUNT, Group


class GroupTest(common.tests.TestCase):
Expand All @@ -22,6 +25,31 @@ def test_default_groups(self):
assert not self.mozillian.get_profile().groups.all(), (
'User should have no groups by default.')

def test_autocomplete_api(self):
self.client.login(email=self.mozillian.email)

r = self.client.get(reverse('group_search'), dict(term='daft'),
HTTP_X_REQUESTED_WITH='XMLHttpRequest')

eq_(r['Content-Type'], 'application/json', 'api uses json header')
assert not 'daft_punk' in json.loads(r.content)

# Make enough users in a group to trigger the autocomplete
robots = Group.objects.create(name='daft_punk')
for i in range(0, AUTO_COMPLETE_COUNT + 1):
email = 'tallowen%s@example.com' % (str(i))
user = User.objects.create_user(email.split('@')[0], email)
user.is_active = True
user.save()
profile = user.get_profile()
profile.groups.add(robots)

assign_autocomplete_to_groups()
r = self.client.get(reverse('group_search'), dict(term='daft'),
HTTP_X_REQUESTED_WITH='XMLHttpRequest')

assert 'daft_punk' in json.loads(r.content)

def test_groups_are_always_lowercase(self):
"""Ensure all groups are saved with lowercase names only."""
Group.objects.create(name='lowercase')
Expand Down
51 changes: 51 additions & 0 deletions apps/groups/tests/test_skills.py
@@ -0,0 +1,51 @@
import json

from django.contrib.auth.models import User

from funfactory.urlresolvers import reverse
from nose.tools import eq_

import common.tests
from groups.cron import assign_autocomplete_to_groups
from groups.models import AUTO_COMPLETE_COUNT, Skill


class SkillzTest(common.tests.TestCase):
def test_autocomplete_api(self):
self.client.login(email=self.mozillian.email)

r = self.client.get(reverse('skill_search'), dict(term='daft'),
HTTP_X_REQUESTED_WITH='XMLHttpRequest')

eq_(r['Content-Type'], 'application/json', 'api uses json header')
assert not 'daft_punk' in json.loads(r.content)

# Make enough users in a group to trigger the autocomplete
robots = Skill.objects.create(name='true love')
for i in range(0, AUTO_COMPLETE_COUNT + 1):
email = 'always_angry%s@example.com' % (str(i))
user = User.objects.create_user(email.split('@')[0], email)
user.is_active = True
user.save()
profile = user.get_profile()
profile.skills.add(robots)

assign_autocomplete_to_groups()
r = self.client.get(reverse('skill_search'), dict(term='true'),
HTTP_X_REQUESTED_WITH='XMLHttpRequest')

assert 'true love' in json.loads(r.content)

def test_pending_user_can_add_skills(self):
"""Ensure pending users can add/edit skills."""
profile = self.pending.get_profile()
assert not profile.skills.all(), 'User should have no skills.'

self.client.login(email=self.pending.email)
self.client.post(reverse('profile.edit'),
dict(last_name='McAwesomepants',
skills='Awesome foo Bar'),
follow=True)

assert profile.skills.all(), (
"Pending user should be able to edit skills.")
4 changes: 3 additions & 1 deletion apps/groups/urls.py
Expand Up @@ -4,11 +4,13 @@
admin.autodiscover()

from . import views
from groups.models import Group, Skill

urlpatterns = patterns('',
url('^groups$', views.index, name='group_index'),
url('^group/(?P<id>\d+)-(?P<url>[^/]+)$', views.show, name='group'),
url('^group/(?P<id>\d+)-(?P<url>[^/]+)/toggle$', views.toggle,
name='group_toggle'),
url('^groups/search$', views.search, name='group_search'),
url('^groups/search$', views.search, dict(searched_object=Group), name='group_search'),
url('^skills/search$', views.search, dict(searched_object=Skill), name='skill_search'),
)
19 changes: 8 additions & 11 deletions apps/groups/views.py
@@ -1,7 +1,7 @@
import json

from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.views.decorators.cache import cache_control
Expand Down Expand Up @@ -36,22 +36,19 @@ def index(request):

@login_required
@cache_control(must_revalidate=True, max_age=3600)
def search(request):
def search(request, searched_object=Group):
"""Simple wildcard search for a group using a GET parameter."""
data = dict(groups=[], search=True)
search_term = request.GET.get('term')

if search_term:
data['groups'] = list(Group.objects
.filter(name__istartswith=search_term,
auto_complete=True)
.values_list('name', flat=True))
data = dict(search=True)
data['groups'] = searched_object.search(request.GET.get('term'))

if request.is_ajax():
return HttpResponse(json.dumps(data['groups']),
mimetype='application/json')
else:

if searched_object == Group:
return render(request, 'groups/index.html', data)
# Raise a 404 if this is a Skill page that isn't ajax
raise Http404


@vouch_required
Expand Down
43 changes: 18 additions & 25 deletions apps/phonebook/forms.py
Expand Up @@ -10,7 +10,7 @@
from tower import ugettext as _, ugettext_lazy as _lazy

from phonebook.models import Invite
from groups.models import Group
from groups.models import Group, Skill
from users.models import User, UserProfile


Expand Down Expand Up @@ -109,10 +109,12 @@ class ProfileForm(UserForm):
photo_delete = forms.BooleanField(label=_lazy(u'Remove Profile Photo'),
required=False)

groups = forms.CharField(
label=_lazy(u'Start typing to add a group (example: Marketing, '
'Support, WebDev, Thunderbird)'),
required=False)
groups = forms.CharField(label=_lazy(
u'Start typing to add a group (example: Marketing, '
'Support, WebDev, Thunderbird)'), required=False)
skills = forms.CharField(label=_lazy(
u'Start typing to add a skill (example: Python, javascript, '
'Graphic Design, User Research)'), required=False)

username = forms.CharField(label=_lazy(u'Username'), max_length=30,
required=False,
Expand Down Expand Up @@ -152,30 +154,21 @@ def clean_groups(self):

return system_groups + new_groups

def clean_skills(self):
if not re.match(r'^[a-zA-Z0-9 .:,-]*$', self.cleaned_data['skills']):
raise forms.ValidationError(_(u'Skills can only contain '
'alphanumeric characters, dashes, '
'spaces.'))
return [s.strip()
for s in self.cleaned_data['skills'].lower().split(',')
if s and ',' not in s]

def save(self, request):
"""Save the data to profile."""
self._save_groups(request)
self.instance.set_membership(Group, self.cleaned_data['groups'])
self.instance.set_membership(Skill, self.cleaned_data['skills'])
super(ProfileForm, self).save(request.user)

def _save_groups(self, request):
"""Parse a string of (usually comma-demilited) groups and save them."""
profile = request.user.get_profile()

# Remove any non-system groups that weren't supplied in this list.
profile.groups.remove(*[g for g in profile.groups.all()
if g.name not in self.cleaned_data['groups']
and not g.system])

# Add/create the rest of the groups
groups_to_add = []
for g in self.cleaned_data['groups']:
(group, created) = Group.objects.get_or_create(name=g)

if not group.system:
groups_to_add.append(group)

profile.groups.add(*groups_to_add)


class VouchForm(happyforms.Form):
"""Vouching is captured via a user's id."""
Expand Down

0 comments on commit b5819c2

Please sign in to comment.