Add collection of user metadata: name and company

This commit is contained in:
Joseph Schorr 2016-11-04 17:57:55 -04:00
parent 909be766c9
commit 0f2eb61f4a
14 changed files with 178 additions and 33 deletions

View file

@ -222,6 +222,9 @@ class DefaultConfig(object):
# Feature Flag: Whether to proxy all direct download URLs in storage via the registry's nginx.
FEATURE_PROXY_STORAGE = False
# Feature Flag: Whether to collect and support user metadata.
FEATURE_USER_METADATA = False
# The namespace to use for library repositories.
# Note: This must remain 'library' until Docker removes their hard-coded namespace for libraries.
# See: https://github.com/docker/docker/blob/master/registry/session.go#L320

View file

@ -337,6 +337,9 @@ class User(BaseModel):
enabled = BooleanField(default=True)
invoice_email_address = CharField(null=True, index=True)
name = CharField(null=True)
company = CharField(null=True)
def delete_instance(self, recursive=False, delete_nullable=False):
# If we are deleting a robot account, only execute the subset of queries necessary.
if self.robot:
@ -372,6 +375,12 @@ class User(BaseModel):
Namespace = User.alias()
class UserPromptTypes(object):
CONFIRM_USERNAME = 'confirm_username'
ENTER_NAME = 'enter_name'
ENTER_COMPANY = 'enter_company'
class UserPromptKind(BaseModel):
name = CharField(index=True)

View file

@ -0,0 +1,44 @@
"""Add user metadata fields
Revision ID: 491a530df230
Revises: 6c7014e84a5e
Create Date: 2016-11-04 18:03:05.237408
"""
# revision identifiers, used by Alembic.
revision = '491a530df230'
down_revision = '6c7014e84a5e'
from alembic import op
import sqlalchemy as sa
from util.migrate import UTF8CharField
def upgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('company', UTF8CharField(length=255), nullable=True))
op.add_column('user', sa.Column('name', UTF8CharField(length=255), nullable=True))
### end Alembic commands ###
op.bulk_insert(tables.userpromptkind,
[
{'name':'enter_name'},
{'name':'enter_company'},
])
def downgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'name')
op.drop_column('user', 'company')
### end Alembic commands ###
op.execute(
(tables.userpromptkind.delete()
.where(tables.userpromptkind.c.name == op.inline_literal('enter_name')))
)
op.execute(
(tables.userpromptkind.delete()
.where(tables.userpromptkind.c.name == op.inline_literal('enter_company')))
)

View file

@ -6,13 +6,14 @@ import uuid
from peewee import JOIN_LEFT_OUTER, IntegrityError, fn
from uuid import uuid4
from datetime import datetime, timedelta
from enum import Enum
from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember,
Team, Repository, TupleSelector, TeamRole, Namespace, Visibility,
EmailConfirmation, Role, db_for_update, random_string_generator,
UserRegion, ImageStorageLocation, QueueItem, TeamMemberInvite,
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger,
UserPromptKind, UserPrompt)
UserPromptKind, UserPrompt, UserPromptTypes)
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
InvalidUsernameException, InvalidEmailAddressException,
TooManyLoginAttemptsException, db_transaction,
@ -28,17 +29,16 @@ logger = logging.getLogger(__name__)
EXPONENTIAL_BACKOFF_SCALE = timedelta(seconds=1)
def hash_password(password, salt=None):
salt = salt or bcrypt.gensalt()
return bcrypt.hashpw(password.encode('utf-8'), salt)
def create_user(username, password, email, auto_verify=False, email_required=True):
def create_user(username, password, email, auto_verify=False, email_required=True, prompts=tuple()):
""" Creates a regular user, if allowed. """
if not validate_password(password):
raise InvalidPasswordException(INVALID_PASSWORD_MESSAGE)
created = create_user_noverify(username, email, email_required=email_required)
created = create_user_noverify(username, email, email_required=email_required, prompts=prompts)
created.password_hash = hash_password(password)
created.verified = auto_verify
created.save()
@ -46,7 +46,7 @@ def create_user(username, password, email, auto_verify=False, email_required=Tru
return created
def create_user_noverify(username, email, email_required=True):
def create_user_noverify(username, email, email_required=True, prompts=tuple()):
if email_required:
if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email)
@ -76,7 +76,11 @@ def create_user_noverify(username, email, email_required=True):
logger.debug('Email and username are unique!')
try:
return User.create(username=username, email=email)
new_user = User.create(username=username, email=email)
for prompt in prompts:
create_user_prompt(new_user, prompt)
return new_user
except Exception as ex:
raise DataModelException(ex.message)
@ -103,6 +107,15 @@ def change_password(user, new_password):
notification.delete_notifications_by_kind(user, 'password_required')
def get_default_user_prompts(features):
prompts = set()
if features.USER_METADATA:
prompts.add(UserPromptTypes.ENTER_NAME)
prompts.add(UserPromptTypes.ENTER_COMPANY)
return prompts
def has_user_prompts(user):
try:
UserPrompt.select().where(UserPrompt.user == user).get()
@ -110,6 +123,7 @@ def has_user_prompts(user):
except UserPrompt.DoesNotExist:
return False
def has_user_prompt(user, prompt_name):
prompt_kind = UserPromptKind.get(name=prompt_name)
@ -341,16 +355,28 @@ def list_entity_robot_permission_teams(entity_name, include_permissions=False):
return TupleSelector(query, fields)
def update_user_metadata(user, name=None, company=None):
""" Updates the metadata associated with the user, including his/her name and company. """
with db_transaction():
user.name = name or user.name
user.company = company or user.company
user.save()
# Remove any prompts associated with the user's metadata being needed.
remove_user_prompt(user, UserPromptTypes.ENTER_NAME)
remove_user_prompt(user, UserPromptTypes.ENTER_COMPANY)
def create_federated_user(username, email, service_name, service_ident,
set_password_notification, metadata={},
email_required=True, confirm_username=False):
new_user = create_user_noverify(username, email, email_required=email_required)
email_required=True, prompts=tuple()):
prompts = set(prompts)
prompts.add(UserPromptTypes.CONFIRM_USERNAME)
new_user = create_user_noverify(username, email, email_required=email_required, prompts=prompts)
new_user.verified = True
new_user.save()
if confirm_username:
create_user_prompt(new_user, 'confirm_username')
service = LoginService.get(LoginService.name == service_name)
FederatedLogin.create(user=new_user, service=service,
service_ident=service_ident,

View file

@ -1,4 +1,5 @@
import logging
import features
from collections import namedtuple
@ -49,11 +50,12 @@ class FederatedUsers(object):
logger.error('Unable to pick a username for user: %s', username)
return (None, 'Unable to pick a username. Please report this to your administrator.')
prompts = model.user.get_default_user_prompts(features)
db_user = model.user.create_federated_user(valid_username, email, self._federated_service,
username,
set_password_notification=False,
email_required=self._requires_email,
confirm_username=True)
prompts=prompts)
else:
# Update the db attributes from the federated service.
if email:

View file

@ -260,8 +260,9 @@ class SuperUserList(ApiResource):
# Create the user.
username = user_information['username']
email = user_information.get('email')
prompts = model.user.get_default_user_prompts(features)
user = model.user.create_user(username, password, email, auto_verify=not features.MAILING,
email_required=features.MAILING)
email_required=features.MAILING, prompts=prompts)
# If mailing is turned on, send the user a verification email.
if features.MAILING:

View file

@ -17,7 +17,7 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository
UserAdminPermission, UserReadPermission, SuperUserPermission)
from data import model
from data.billing import get_plan
from data.database import Repository as RepositoryTable
from data.database import Repository as RepositoryTable, UserPromptTypes
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, require_user_admin, parse_args,
query_param, require_scope, format_date, show_if,
@ -180,8 +180,8 @@ class User(ApiResource):
},
'invite_code': {
'type': 'string',
'description': 'The optional invite code'
}
'description': 'The optional invite code',
},
}
},
'UpdateUser': {
@ -212,7 +212,15 @@ class User(ApiResource):
'invoice_email_address': {
'type': ['string', 'null'],
'description': 'Custom email address for receiving invoices',
}
},
'name': {
'type': 'string',
'description': 'The optional entered name for the user',
},
'company': {
'type': 'string',
'description': 'The optional entered company for the user',
},
},
},
'UserView': {
@ -326,6 +334,9 @@ class User(ApiResource):
else:
model.user.update_email(user, new_email, auto_verify=not features.MAILING)
if 'name' in user_data or 'company' in user_data:
model.user.update_user_metadata(user, user_data.get('name'), user_data.get('company'))
# Check for username rename. A username can be renamed if the feature is enabled OR the user
# currently has a confirm_username prompt.
if 'username' in user_data:
@ -370,10 +381,12 @@ class User(ApiResource):
raise request_error(message='Email address is required')
try:
prompts = model.user.get_default_user_prompts(features)
new_user = model.user.create_user(user_data['username'], user_data['password'],
user_data.get('email'),
auto_verify=not features.MAILING,
email_required=features.MAILING)
email_required=features.MAILING,
prompts=prompts)
email_address_confirmed = handle_invite_code(invite_code, new_user)
if features.MAILING and not email_address_confirmed:

View file

@ -1,5 +1,4 @@
import logging
import requests
from flask import request, redirect, url_for, Blueprint
@ -63,9 +62,11 @@ def conduct_oauth_login(service, user_id, username, email, metadata={}):
new_username = valid
break
prompts = model.user.get_default_user_prompts(features)
to_login = model.user.create_federated_user(new_username, email, service_name.lower(),
user_id, set_password_notification=True,
metadata=metadata, confirm_username=True)
metadata=metadata,
prompts=prompts)
# Success, tell analytics
analytics.track(to_login.username, 'register', {'service': service_name.lower()})

View file

@ -399,7 +399,10 @@ def confirm_email():
user_analytics.change_email(old_email, new_email)
common_login(user)
return redirect(url_for('web.user', tab='email') if new_email else url_for('web.index'))
if model.user.has_user_prompts(user):
return redirect(url_for('web.updateuser'))
else:
return redirect(url_for('web.user', tab='email') if new_email else url_for('web.index'))
@web.route('/recovery', methods=['GET'])

View file

@ -397,6 +397,8 @@ def initialize_database():
LabelSourceType.create(name='internal')
UserPromptKind.create(name='confirm_username')
UserPromptKind.create(name='enter_name')
UserPromptKind.create(name='enter_company')
def wipe_database():

View file

@ -21,7 +21,11 @@
UserService.updateUserIn($scope, function(user) {
if (!user.anonymous) {
$location.path('/repository/');
if (user.prompts && user.prompts.length) {
$location.path('/updateuser/');
} else {
$location.path('/repository/');
}
}
});

View file

@ -10,6 +10,7 @@
function UpdateUserCtrl($scope, UserService, $location, ApiService) {
$scope.state = 'loading';
$scope.metadata = {};
UserService.updateUserIn($scope, function(user) {
if (!user.prompts || !user.prompts.length) {
@ -43,15 +44,17 @@
});
};
$scope.updateUsername = function(username) {
$scope.state = 'updating';
var data = {
'username': username
};
ApiService.changeUserDetails(data).then(function() {
window.location = '/';
}, ApiService.errorDisplay('Could not update username'));
$scope.updateUser = function(data) {
$scope.state = 'updating';
ApiService.changeUserDetails(data).then(function() {
UserService.load(function(updated) {
if (updated.prompts.length) {
$scope.state = 'editing';
} else {
$location.url('/');
}
});
}, ApiService.errorDisplay('Could not update user information'));
};
$scope.hasPrompt = function(user, prompt_name) {

View file

@ -1,6 +1,6 @@
<div class="cor-loader-inline" ng-if="user.anonymous || state == 'updating'"></div>
<!-- TODO: Support additional kinds of prompts here -->
<!-- Confirm username -->
<div class="update-user" ng-show="hasPrompt(user, 'confirm_username') && state != 'updating'">
<h2>Confirm Username</h2>
<p>
@ -8,7 +8,7 @@
Docker CLI guidelines for use as a namespace in <span class="registry-title"></span>.
</p>
<p>Please confirm the selected username or enter a different username below:</p>
<form name="usernameForm" ng-submit="updateUsername(username)">
<form name="usernameForm" ng-submit="updateUser({'username': username})">
<div class="namespace-input" binding="username" is-back-incompat="isBackIncompat"
namespace-title="Username" style="margin-bottom: 20px;"
has-external-error="state == 'existing'"></div>
@ -33,4 +33,38 @@
<i class="fa fa-exclamation-triangle"></i> Note: Usernames with dots or dashes are incompatible with Docker verion 1.8 or older
</span>
</form>
</div>
<!-- Enter metadata -->
<div class="update-user" ng-show="!hasPrompt(user, 'confirm_username') && (hasPrompt(user, 'enter_name') || hasPrompt(user, 'enter_company')) && state != 'updating'">
<h2>Tell us a bit more about yourself</h2>
<div>This information will be displayed in your user profile.</div>
<form name="metadataForm" ng-submit="updateUser(metadata)" style="margin-top: 20px;">
<div class="form-group nested">
<label for="name">Name</label>
<div class="field-row">
<span class="field-container">
<input type="text" class="form-control" placeholder="Name" ng-model="metadata.name"></span>
</span>
</div>
</div>
<div class="form-group nested">
<label for="firstName">Company</label>
<div class="field-row">
<span class="field-container">
<input type="text" class="form-control" placeholder="Company name" ng-model="metadata.company"></span>
</span>
</div>
</div>
<div style="margin-top: 20px">
<input type="submit" class="btn btn-primary" value="Save Details"
ng-disabled="!metadata.name && !metadata.company">
<button class="btn btn-default"
ng-click="updateUser({'company': '', 'name': ''})">
No thanks</button>
</div>
</form>
</div>

Binary file not shown.