Add collection of user metadata: name and company
This commit is contained in:
parent
909be766c9
commit
0f2eb61f4a
14 changed files with 178 additions and 33 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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')))
|
||||
)
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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()})
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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/');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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.
Reference in a new issue