Skip to content

Commit

Permalink
Members team and team domain. Fixes #28, #29, #108
Browse files Browse the repository at this point in the history
  • Loading branch information
jace committed Jan 22, 2015
1 parent 07fc88e commit cabd696
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 21 deletions.
32 changes: 32 additions & 0 deletions alembic/versions/d055b3e2c89_members_team.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Members team
Revision ID: d055b3e2c89
Revises: 50c29617571d
Create Date: 2015-01-23 01:58:07.712054
"""

# revision identifiers, used by Alembic.
revision = 'd055b3e2c89'
down_revision = '50c29617571d'

from alembic import op
import sqlalchemy as sa


def upgrade():
op.drop_constraint('fk_organization_owners_id', 'organization', type_='foreignkey')
op.create_foreign_key('organization_owners_id_fkey', 'organization', 'team', ['owners_id'], ['id'])
op.add_column('organization', sa.Column('members_id', sa.Integer(), nullable=True))
op.create_foreign_key('organization_members_id_fkey', 'organization', 'team', ['members_id'], ['id'])
op.add_column('team', sa.Column('domain', sa.Unicode(length=253), nullable=True))
op.create_index(op.f('ix_team_domain'), 'team', ['domain'], unique=False)


def downgrade():
op.drop_index(op.f('ix_team_domain'), table_name='team')
op.drop_column('team', 'domain')
op.drop_constraint('organization_members_id_fkey', 'organization', type_='foreignkey')
op.drop_column('organization', 'members_id')
op.drop_constraint('organization_owners_id_fkey', 'organization', type_='foreignkey')
op.create_foreign_key('fk_organization_owners_id', 'organization', 'team', ['owners_id'], ['id'])
20 changes: 15 additions & 5 deletions lastuser_core/models/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import urlparse
from hashlib import sha256
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import load_only
from sqlalchemy.orm.query import Query as QueryBaseClass
from sqlalchemy.orm.collections import attribute_mapped_collection
from coaster.utils import buid, newsecret

Expand Down Expand Up @@ -421,12 +423,20 @@ def all(cls, users):
"""
Return all AuthToken for the specified users.
"""
if len(users) == 0:
return []
elif len(users) == 1:
return cls.query.filter_by(user=users[0]).all()
if isinstance(users, QueryBaseClass):
count = users.count()
if count == 1:
return cls.query.filter_by(user=users.first()).all()
elif count > 1:
return cls.query.filter(AuthToken.user_id.in_(users.options(load_only('id')))).all()
else:
return cls.query.filter(AuthToken.user_id.in_([u.id for u in users])).all()
count = len(users)
if count == 1:
return cls.query.filter_by(user=users[0]).all()
elif count > 1:
return cls.query.filter(AuthToken.user_id.in_([u.id for u in users])).all()

return []


class Permission(BaseMixin, db.Model):
Expand Down
55 changes: 53 additions & 2 deletions lastuser_core/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ def add_email(self, email, primary=False):
emailob.primary = False
useremail = UserEmail(user=self, email=email, primary=primary)
db.session.add(useremail)
with db.session.no_autoflush:
for team in Team.query.filter_by(domain=useremail.domain):
if self not in team.users:
team.users.append(self)
return useremail

def del_email(self, email):
Expand Down Expand Up @@ -250,6 +254,20 @@ def organizations_owned_ids(self):
"""
return list(set([team.org.id for team in self.teams if team.org.owners == team]))

def organizations_memberof(self):
"""
Return the organizations this user is an owner of.
"""
return sorted(set([team.org for team in self.teams if team.org.members == team]),
key=lambda o: o.title)

def organizations_memberof_ids(self):
"""
Return the database ids of the organizations this user is an owner of. This is used
for database queries.
"""
return list(set([team.org.id for team in self.teams if team.org.members == team]))

def is_profile_complete(self):
"""
Return True if profile is complete (fullname, username and email are present), False
Expand Down Expand Up @@ -404,9 +422,13 @@ class Organization(BaseMixin, db.Model):
# a circular dependency. The post_update flag on the relationship tackles the circular
# dependency within SQLAlchemy.
owners_id = db.Column(None, db.ForeignKey('team.id',
use_alter=True, name='fk_organization_owners_id'), nullable=True)
use_alter=True, name='organization_owners_id_fkey'), nullable=True)
owners = db.relationship('Team', primaryjoin='Organization.owners_id == Team.id',
uselist=False, cascade='all', post_update=True) # No delete-orphan cascade here
members_id = db.Column(None, db.ForeignKey('team.id',
use_alter=True, name='organization_members_id_fkey'), nullable=True)
members = db.relationship('Team', primaryjoin='Organization.members_id == Team.id',
uselist=False, cascade='all', post_update=True) # No delete-orphan cascade here
userid = db.Column(db.String(22), unique=True, nullable=False, default=buid)
_name = db.Column('name', db.Unicode(80), unique=True, nullable=True)
title = db.Column(db.Unicode(80), default=u'', nullable=False)
Expand All @@ -426,8 +448,33 @@ class Organization(BaseMixin, db.Model):

def __init__(self, *args, **kwargs):
super(Organization, self).__init__(*args, **kwargs)
self.make_teams()

def make_teams(self):
if self.owners is None:
self.owners = Team(title=u"Owners", org=self)
if self.members is None:
self.members = Team(title=u"Members", org=self)

@property
def domain(self):
if self.members:
return self.members.domain

@domain.setter
def domain(self, value):
if not value:
value = None
if not self.members:
self.make_teams()
if value and value != self.members.domain:
# Look for team members based on domain, but only if the domain value was
# changed
with db.session.no_autoflush:
for useremail in UserEmail.query.filter_by(domain=value).join(User):
if useremail.user not in self.members.users:
self.members.users.append(useremail.user)
self.members.domain = value

@hybrid_property
def name(self):
Expand Down Expand Up @@ -541,9 +588,13 @@ class Team(BaseMixin, db.Model):
org_id = db.Column(None, db.ForeignKey('organization.id'), nullable=False)
org = db.relationship(Organization, primaryjoin=org_id == Organization.id,
backref=db.backref('teams', order_by=title, cascade='all, delete-orphan'))
users = db.relationship(User, secondary='team_membership',
users = db.relationship(User, secondary='team_membership', lazy='dynamic',
backref='teams') # No cascades here! Cascades will delete users

#: Email domain for this team. Any users with a matching email address
#: will be auto-added to this team
domain = db.Column(db.Unicode(253), nullable=True, index=True)

#: Client id that created this team
client_id = db.Column(None, db.ForeignKey('client.id',
use_alter=True, name='team_client_id_fkey'), nullable=True)
Expand Down
3 changes: 2 additions & 1 deletion lastuser_oauth/views/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def verifyscope(scope, client):
if resource_registry[item]['trusted'] and not client.trusted:
raise ScopeException(u"The resource {scope} is only available to trusted clients".format(scope=item))
internal_resources.append(item)
else: # Validation is only required for non-internal resources
else:

# Validation 0: Is this an internal wildcard resource?
if item.endswith('/*'):
Expand All @@ -49,6 +49,7 @@ def verifyscope(scope, client):
if found_internal:
continue # Continue to next item in scope, skipping the following

# Further validation is only required for non-internal resources
# Validation 1: namespace:resource/action is properly formatted
if ':' not in item:
raise ScopeException(u"No namespace specified for external resource ‘{scope}’ in scope".format(scope=item))
Expand Down
3 changes: 3 additions & 0 deletions lastuser_ui/forms/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ class OrganizationForm(Form):
title = wtforms.TextField("Organization name", validators=[wtforms.validators.Required()])
name = AnnotatedTextField("Username", validators=[wtforms.validators.Required()],
prefix=u"https://hasgeek.com/…")
domain = wtforms.RadioField("Domain",
description=u"Users with an email address at this domain will automatically become members of this organization",
validators=[wtforms.validators.Optional()])

def validate_name(self, field):
if not valid_username(field.data):
Expand Down
14 changes: 7 additions & 7 deletions lastuser_ui/forms/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ def validate_email(self, field):
existing = UserEmail.get(email=field.data)
if existing is not None:
if existing.user == g.user:
raise wtforms.ValidationError("You have already registered this email address.")
raise wtforms.ValidationError("You have already registered this email address")
else:
raise wtforms.ValidationError("This email address has already been claimed.")
raise wtforms.ValidationError("This email address has already been claimed")
existing = UserEmailClaim.get(email=field.data, user=g.user)
if existing is not None:
raise wtforms.ValidationError("This email address is pending verification.")
raise wtforms.ValidationError("This email address is pending verification")


class NewPhoneForm(Form):
Expand All @@ -39,12 +39,12 @@ def validate_phone(self, field):
existing = UserPhone.get(phone=field.data)
if existing is not None:
if existing.user == g.user:
raise wtforms.ValidationError("You have already registered this phone number.")
raise wtforms.ValidationError("You have already registered this phone number")
else:
raise wtforms.ValidationError("This phone number has already been claimed.")
raise wtforms.ValidationError("This phone number has already been claimed")
existing = UserPhoneClaim.get(phone=field.data, user=g.user)
if existing is not None:
raise wtforms.ValidationError("This phone number is pending verification.")
raise wtforms.ValidationError("This phone number is pending verification")
# Step 1: Remove punctuation in number
field.data = strip_phone(field.data)
# Step 2: Validate number format
Expand All @@ -60,4 +60,4 @@ class VerifyPhoneForm(Form):

def validate_verification_code(self, field):
if self.phoneclaim.verification_code != field.data:
raise wtforms.ValidationError("Verification code does not match.")
raise wtforms.ValidationError("Verification code does not match")
2 changes: 1 addition & 1 deletion lastuser_ui/templates/org_info.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ <h2>Teams</h2>
<li>
<strong>{{ team.title }}</strong>
(<a href="{{ url_for('.team_edit', name=org.name, userid=team.userid) }}">edit</a>
{%- if team != org.owners %},
{%- if team != org.owners and team != org.members %},
<a href="{{ url_for('.team_delete', name=org.name, userid=team.userid) }}">delete</a>
{%- endif %})
<ol>{% for user in team.users %}<li>{{ user.pickername }}</li>{% endfor %}</ol>
Expand Down
26 changes: 21 additions & 5 deletions lastuser_ui/views/org.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-

from flask import g, current_app, render_template, url_for, abort, redirect, make_response, request
from flask import g, current_app, render_template, url_for, abort, redirect, make_response, request, Markup, escape
from baseframe.forms import render_form, render_redirect, render_delete_sqla
from baseframe.staticdata import webmail_domains
from coaster.views import load_model, load_models

from lastuser_core.models import db, Organization, Team, User
Expand All @@ -10,8 +11,20 @@
from .. import lastuser_ui
from ..forms.org import OrganizationForm, TeamForm

# --- Routes: Organizations ---------------------------------------------------

def user_org_domains(user, org=None):
domains = [email.domain for email in user.emails if email.domain not in webmail_domains]
choices = [(d, d) for d in domains]
if org and org.domain and org.domain not in domains:
choices.insert(0, (org.domain, Markup("%s <em>(Current setting)</em>" % escape(org.domain))))
if not domains:
choices.insert(0, (u'', Markup("<em>(You do not have a verified non-webmail email address yet)</em>")))
else:
choices.insert(0, (u'', Markup("<em>(No domain associated with this organization)</em>")))
return choices


# --- Routes: Organizations ---------------------------------------------------

@lastuser_ui.route('/organizations')
@requires_login
Expand All @@ -23,17 +36,19 @@ def org_list():
@requires_login
def org_new():
form = OrganizationForm()
form.domain.choices = user_org_domains(g.user)
form.name.description = current_app.config.get('ORG_NAME_REASON')
form.title.description = current_app.config.get('ORG_TITLE_REASON')
if form.validate_on_submit():
org = Organization()
form.populate_obj(org)
org.owners.users.append(g.user)
org.members.users.append(g.user)
db.session.add(org)
db.session.commit()
org_data_changed.send(org, changes=['new'], user=g.user)
return render_redirect(url_for('.org_info', name=org.name), code=303)
return render_form(form=form, title="New Organization", formid="org_new", submit="Create", ajax=False)
return render_form(form=form, title="New organization", formid="org_new", submit="Create", ajax=False)


@lastuser_ui.route('/organizations/<name>')
Expand All @@ -48,14 +63,15 @@ def org_info(org):
@load_model(Organization, {'name': 'name'}, 'org', permission='edit')
def org_edit(org):
form = OrganizationForm(obj=org)
form.domain.choices = user_org_domains(g.user, org)
form.name.description = current_app.config.get('ORG_NAME_REASON')
form.title.description = current_app.config.get('ORG_TITLE_REASON')
if form.validate_on_submit():
form.populate_obj(org)
db.session.commit()
org_data_changed.send(org, changes=['edit'], user=g.user)
return render_redirect(url_for('.org_info', name=org.name), code=303)
return render_form(form=form, title="New Organization", formid="org_edit", submit="Save", ajax=False)
return render_form(form=form, title="Edit organization", formid="org_edit", submit="Save", ajax=False)


@lastuser_ui.route('/organizations/<name>/delete', methods=['GET', 'POST'])
Expand Down Expand Up @@ -129,7 +145,7 @@ def team_edit(org, team):
permission='delete'
)
def team_delete(org, team):
if team == org.owners:
if team == org.owners or team == org.members:
abort(403)
if request.method == 'POST':
team_data_changed.send(team, changes=['delete'], user=g.user)
Expand Down

0 comments on commit cabd696

Please sign in to comment.