Add support for deleting namespaces (users, organizations)
Fixes #102 Fixes #105
This commit is contained in:
parent
a74e94fb67
commit
73eb66eac5
23 changed files with 407 additions and 33 deletions
14
app.py
14
app.py
|
@ -208,11 +208,17 @@ dex_login = DexOAuthConfig(app.config, 'DEX_LOGIN_CONFIG')
|
||||||
|
|
||||||
oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login, dex_login]
|
oauth_apps = [github_login, github_trigger, gitlab_trigger, google_login, dex_login]
|
||||||
|
|
||||||
image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf)
|
image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf, has_namespace=False)
|
||||||
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf,
|
||||||
reporter=BuildMetricQueueReporter(metric_queue))
|
reporter=BuildMetricQueueReporter(metric_queue),
|
||||||
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf)
|
has_namespace=True)
|
||||||
secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf)
|
notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf, has_namespace=True)
|
||||||
|
secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf,
|
||||||
|
has_namespace=False)
|
||||||
|
|
||||||
|
all_queues = [image_replication_queue, dockerfile_build_queue, notification_queue,
|
||||||
|
secscan_notification_queue]
|
||||||
|
|
||||||
secscan_api = SecurityScannerAPI(app, app.config, storage)
|
secscan_api = SecurityScannerAPI(app, app.config, storage)
|
||||||
|
|
||||||
# Check for a key in config. If none found, generate a new signing key for Docker V2 manifests.
|
# Check for a key in config. If none found, generate a new signing key for Docker V2 manifests.
|
||||||
|
|
|
@ -16,6 +16,7 @@ from buildman.jobutil.workererror import WorkerError
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.database import BUILD_PHASE
|
from data.database import BUILD_PHASE
|
||||||
|
from data.model import InvalidRepositoryBuildException
|
||||||
|
|
||||||
HEARTBEAT_DELTA = datetime.timedelta(seconds=30)
|
HEARTBEAT_DELTA = datetime.timedelta(seconds=30)
|
||||||
BUILD_HEARTBEAT_DELAY = datetime.timedelta(seconds=30)
|
BUILD_HEARTBEAT_DELAY = datetime.timedelta(seconds=30)
|
||||||
|
@ -241,8 +242,13 @@ class BuildComponent(BaseComponent):
|
||||||
# Parse and update the phase and the status_dict. The status dictionary contains
|
# Parse and update the phase and the status_dict. The status dictionary contains
|
||||||
# the pull/push progress, as well as the current step index.
|
# the pull/push progress, as well as the current step index.
|
||||||
with self._build_status as status_dict:
|
with self._build_status as status_dict:
|
||||||
|
try:
|
||||||
if self._build_status.set_phase(phase, log_data.get('status_data')):
|
if self._build_status.set_phase(phase, log_data.get('status_data')):
|
||||||
logger.debug('Build %s has entered a new phase: %s', self.builder_realm, phase)
|
logger.debug('Build %s has entered a new phase: %s', self.builder_realm, phase)
|
||||||
|
except InvalidRepositoryBuildException:
|
||||||
|
build_id = self._current_job.repo_build.uuid
|
||||||
|
logger.info('Build %s was not found; repo was probably deleted', build_id)
|
||||||
|
return
|
||||||
|
|
||||||
BuildComponent._process_pushpull_status(status_dict, phase, log_data, self._image_info)
|
BuildComponent._process_pushpull_status(status_dict, phase, log_data, self._image_info)
|
||||||
|
|
||||||
|
@ -300,7 +306,12 @@ class BuildComponent(BaseComponent):
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
self._build_status.set_phase(BUILD_PHASE.COMPLETE)
|
self._build_status.set_phase(BUILD_PHASE.COMPLETE)
|
||||||
|
except InvalidRepositoryBuildException:
|
||||||
|
logger.info('Build %s was not found; repo was probably deleted', build_id)
|
||||||
|
return
|
||||||
|
|
||||||
trollius.async(self._build_finished(BuildJobResult.COMPLETE))
|
trollius.async(self._build_finished(BuildJobResult.COMPLETE))
|
||||||
|
|
||||||
# Label the pushed manifests with the build metadata.
|
# Label the pushed manifests with the build metadata.
|
||||||
|
|
|
@ -359,8 +359,15 @@ class User(BaseModel):
|
||||||
raise RuntimeError('Non-recursive delete on user.')
|
raise RuntimeError('Non-recursive delete on user.')
|
||||||
|
|
||||||
# These models don't need to use transitive deletes, because the referenced objects
|
# These models don't need to use transitive deletes, because the referenced objects
|
||||||
# are cleaned up directly
|
# are cleaned up directly in the model.
|
||||||
skip_transitive_deletes = {Image}
|
skip_transitive_deletes = {Image, Repository, Team, RepositoryBuild, ServiceKeyApproval,
|
||||||
|
RepositoryBuildTrigger, ServiceKey, RepositoryPermission,
|
||||||
|
TeamMemberInvite, Star, RepositoryAuthorizedEmail, TeamMember,
|
||||||
|
RepositoryTag, PermissionPrototype, DerivedStorageForImage,
|
||||||
|
TagManifest, AccessToken, OAuthAccessToken, BlobUpload,
|
||||||
|
RepositoryNotification, OAuthAuthorizationCode,
|
||||||
|
RepositoryActionCount, TagManifestLabel}
|
||||||
|
|
||||||
delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes)
|
delete_instance_filtered(self, User, delete_nullable, skip_transitive_deletes)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,8 @@ from datetime import datetime, timedelta
|
||||||
from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember,
|
from data.database import (User, LoginService, FederatedLogin, RepositoryPermission, TeamMember,
|
||||||
Team, Repository, TupleSelector, TeamRole, Namespace, Visibility,
|
Team, Repository, TupleSelector, TeamRole, Namespace, Visibility,
|
||||||
EmailConfirmation, Role, db_for_update, random_string_generator,
|
EmailConfirmation, Role, db_for_update, random_string_generator,
|
||||||
UserRegion, ImageStorageLocation)
|
UserRegion, ImageStorageLocation, QueueItem, TeamMemberInvite,
|
||||||
|
ServiceKeyApproval, OAuthApplication, RepositoryBuildTrigger)
|
||||||
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
|
from data.model import (DataModelException, InvalidPasswordException, InvalidRobotException,
|
||||||
InvalidUsernameException, InvalidEmailAddressException,
|
InvalidUsernameException, InvalidEmailAddressException,
|
||||||
TooManyUsersException, TooManyLoginAttemptsException, db_transaction,
|
TooManyUsersException, TooManyLoginAttemptsException, db_transaction,
|
||||||
|
@ -657,11 +658,81 @@ def detach_external_login(user, service_name):
|
||||||
FederatedLogin.service == service).execute()
|
FederatedLogin.service == service).execute()
|
||||||
|
|
||||||
|
|
||||||
def delete_user(user):
|
def get_solely_admined_organizations(user_obj):
|
||||||
|
""" Returns the organizations admined solely by the given user. """
|
||||||
|
orgs = (User.select()
|
||||||
|
.where(User.organization == True)
|
||||||
|
.join(Team)
|
||||||
|
.join(TeamRole)
|
||||||
|
.where(TeamRole.name == 'admin')
|
||||||
|
.switch(Team)
|
||||||
|
.join(TeamMember)
|
||||||
|
.where(TeamMember.user == user_obj)
|
||||||
|
.distinct())
|
||||||
|
|
||||||
|
# Filter to organizations where the user is the sole admin.
|
||||||
|
solely_admined = []
|
||||||
|
for org in orgs:
|
||||||
|
admin_user_count = (TeamMember.select()
|
||||||
|
.join(Team)
|
||||||
|
.join(TeamRole)
|
||||||
|
.where(Team.organization == org, TeamRole.name == 'admin')
|
||||||
|
.switch(TeamMember)
|
||||||
|
.join(User)
|
||||||
|
.where(User.robot == False)
|
||||||
|
.distinct()
|
||||||
|
.count())
|
||||||
|
|
||||||
|
if admin_user_count == 1:
|
||||||
|
solely_admined.append(org)
|
||||||
|
|
||||||
|
return solely_admined
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(user, queues, force=False):
|
||||||
|
if not force and not user.organization:
|
||||||
|
# Ensure that the user is not the sole admin for any organizations. If so, then the user
|
||||||
|
# cannot be deleted before those organizations are deleted or reassigned.
|
||||||
|
organizations = get_solely_admined_organizations(user)
|
||||||
|
if len(organizations) > 0:
|
||||||
|
message = 'Cannot delete %s as you are the only admin for organizations: ' % user.username
|
||||||
|
for index, org in enumerate(organizations):
|
||||||
|
if index > 0:
|
||||||
|
message = message + ', '
|
||||||
|
|
||||||
|
message = message + org.username
|
||||||
|
|
||||||
|
raise DataModelException(message)
|
||||||
|
|
||||||
|
# Delete all queue items for the user.
|
||||||
|
for queue in queues:
|
||||||
|
queue.delete_namespaced_items(user.username)
|
||||||
|
|
||||||
# Delete any repositories under the user's namespace.
|
# Delete any repositories under the user's namespace.
|
||||||
for repo in list(Repository.select().where(Repository.namespace_user == user)):
|
for repo in list(Repository.select().where(Repository.namespace_user == user)):
|
||||||
repository.purge_repository(user.username, repo.name)
|
repository.purge_repository(user.username, repo.name)
|
||||||
|
|
||||||
|
if user.organization:
|
||||||
|
# Delete the organization's teams.
|
||||||
|
for team in Team.select().where(Team.organization == user):
|
||||||
|
team.delete_instance(recursive=True)
|
||||||
|
|
||||||
|
# Delete any OAuth approvals and tokens associated with the user.
|
||||||
|
for app in OAuthApplication.select().where(OAuthApplication.organization == user):
|
||||||
|
app.delete_instance(recursive=True)
|
||||||
|
else:
|
||||||
|
# Remove the user from any teams in which they are a member.
|
||||||
|
TeamMember.delete().where(TeamMember.user == user).execute()
|
||||||
|
|
||||||
|
# Delete any repository buildtriggers where the user is the connected user.
|
||||||
|
triggers = RepositoryBuildTrigger.select().where(RepositoryBuildTrigger.connected_user == user)
|
||||||
|
for trigger in triggers:
|
||||||
|
trigger.delete_instance(recursive=True, delete_nullable=False)
|
||||||
|
|
||||||
|
# Null out any service key approvals. We technically lose information here, but its better than
|
||||||
|
# falling and only occurs if a superuser is being deleted.
|
||||||
|
ServiceKeyApproval.update(approver=None).where(ServiceKeyApproval.approver == user).execute()
|
||||||
|
|
||||||
# Delete the user itself.
|
# Delete the user itself.
|
||||||
user.delete_instance(recursive=True, delete_nullable=True)
|
user.delete_instance(recursive=True, delete_nullable=True)
|
||||||
|
|
||||||
|
|
|
@ -33,12 +33,14 @@ class BuildMetricQueueReporter(object):
|
||||||
class WorkQueue(object):
|
class WorkQueue(object):
|
||||||
""" Work queue defines methods for interacting with a queue backed by the database. """
|
""" Work queue defines methods for interacting with a queue backed by the database. """
|
||||||
def __init__(self, queue_name, transaction_factory,
|
def __init__(self, queue_name, transaction_factory,
|
||||||
canonical_name_match_list=None, reporter=None, metric_queue=None):
|
canonical_name_match_list=None, reporter=None, metric_queue=None,
|
||||||
|
has_namespace=False):
|
||||||
self._queue_name = queue_name
|
self._queue_name = queue_name
|
||||||
self._reporter = reporter
|
self._reporter = reporter
|
||||||
self._metric_queue = metric_queue
|
self._metric_queue = metric_queue
|
||||||
self._transaction_factory = transaction_factory
|
self._transaction_factory = transaction_factory
|
||||||
self._currently_processing = False
|
self._currently_processing = False
|
||||||
|
self._has_namespaced_items = has_namespace
|
||||||
|
|
||||||
if canonical_name_match_list is None:
|
if canonical_name_match_list is None:
|
||||||
self._canonical_name_match_list = []
|
self._canonical_name_match_list = []
|
||||||
|
@ -130,6 +132,15 @@ class WorkQueue(object):
|
||||||
except QueueItem.DoesNotExist:
|
except QueueItem.DoesNotExist:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def delete_namespaced_items(self, namespace, subpath=None):
|
||||||
|
""" Deletes all items in this queue that exist under the given namespace. """
|
||||||
|
if not self._has_namespaced_items:
|
||||||
|
return False
|
||||||
|
|
||||||
|
subpath_query = '%s/' % subpath if subpath else ''
|
||||||
|
queue_prefix = '%s/%s/%s%%' % (self._queue_name, namespace, subpath_query)
|
||||||
|
QueueItem.delete().where(QueueItem.queue_name ** queue_prefix).execute()
|
||||||
|
|
||||||
def put(self, canonical_name_list, message, available_after=0, retries_remaining=5):
|
def put(self, canonical_name_list, message, available_after=0, retries_remaining=5):
|
||||||
"""
|
"""
|
||||||
Put an item, if it shouldn't be processed for some number of seconds,
|
Put an item, if it shouldn't be processed for some number of seconds,
|
||||||
|
|
|
@ -6,10 +6,10 @@ from flask import request
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import billing as stripe, avatar
|
from app import billing as stripe, avatar, all_queues
|
||||||
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
||||||
related_user_resource, internal_only, require_user_admin, log_action,
|
related_user_resource, internal_only, require_user_admin, log_action,
|
||||||
show_if, path_param, require_scope)
|
show_if, path_param, require_scope, require_fresh_login)
|
||||||
from endpoints.exception import Unauthorized, NotFound
|
from endpoints.exception import Unauthorized, NotFound
|
||||||
from endpoints.api.user import User, PrivateRepositories
|
from endpoints.api.user import User, PrivateRepositories
|
||||||
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
|
from auth.permissions import (AdministerOrganizationPermission, OrganizationMemberPermission,
|
||||||
|
@ -199,6 +199,23 @@ class Organization(ApiResource):
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
|
|
||||||
|
@require_scope(scopes.ORG_ADMIN)
|
||||||
|
@require_fresh_login
|
||||||
|
@nickname('deleteOrganization')
|
||||||
|
def delete(self, orgname):
|
||||||
|
""" Deletes the specified organization. """
|
||||||
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
|
if permission.can():
|
||||||
|
try:
|
||||||
|
org = model.organization.get_organization(orgname)
|
||||||
|
except model.InvalidOrganizationException:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
model.user.delete_user(org, all_queues)
|
||||||
|
|
||||||
|
return 'Deleted', 204
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/organization/<orgname>/private')
|
@resource('/v1/organization/<orgname>/private')
|
||||||
@path_param('orgname', 'The name of the organization')
|
@path_param('orgname', 'The name of the organization')
|
||||||
@internal_only
|
@internal_only
|
||||||
|
|
|
@ -8,8 +8,8 @@ from datetime import timedelta, datetime
|
||||||
|
|
||||||
from flask import request, abort
|
from flask import request, abort
|
||||||
|
|
||||||
|
from app import dockerfile_build_queue
|
||||||
from data import model
|
from data import model
|
||||||
from data.database import Repository as RepositoryTable
|
|
||||||
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
|
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
|
||||||
require_repo_read, require_repo_write, require_repo_admin,
|
require_repo_read, require_repo_write, require_repo_admin,
|
||||||
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
|
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
|
||||||
|
@ -353,9 +353,14 @@ class Repository(RepositoryParamResource):
|
||||||
""" Delete a repository. """
|
""" Delete a repository. """
|
||||||
model.repository.purge_repository(namespace, repository)
|
model.repository.purge_repository(namespace, repository)
|
||||||
user = model.user.get_namespace_user(namespace)
|
user = model.user.get_namespace_user(namespace)
|
||||||
|
|
||||||
if features.BILLING:
|
if features.BILLING:
|
||||||
plan = get_namespace_plan(namespace)
|
plan = get_namespace_plan(namespace)
|
||||||
check_repository_usage(user, plan)
|
check_repository_usage(user, plan)
|
||||||
|
|
||||||
|
# Remove any builds from the queue.
|
||||||
|
dockerfile_build_queue.delete_namespaced_items(namespace, repository)
|
||||||
|
|
||||||
log_action('delete_repo', namespace,
|
log_action('delete_repo', namespace,
|
||||||
{'repo': repository, 'namespace': namespace})
|
{'repo': repository, 'namespace': namespace})
|
||||||
return 'Deleted', 204
|
return 'Deleted', 204
|
||||||
|
|
|
@ -11,7 +11,8 @@ from flask import request, make_response, jsonify
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, avatar, superusers, authentication, config_provider, license_validator
|
from app import (app, avatar, superusers, authentication, config_provider, license_validator,
|
||||||
|
all_queues)
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.permissions import SuperUserPermission
|
from auth.permissions import SuperUserPermission
|
||||||
|
@ -366,7 +367,7 @@ class SuperUserManagement(ApiResource):
|
||||||
if superusers.is_superuser(username):
|
if superusers.is_superuser(username):
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
model.user.delete_user(user)
|
model.user.delete_user(user, all_queues, force=True)
|
||||||
return 'Deleted', 204
|
return 'Deleted', 204
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
@ -500,7 +501,7 @@ class SuperUserOrganizationManagement(ApiResource):
|
||||||
if SuperUserPermission().can():
|
if SuperUserPermission().can():
|
||||||
org = model.organization.get_organization(name)
|
org = model.organization.get_organization(name)
|
||||||
|
|
||||||
model.user.delete_user(org)
|
model.user.delete_user(org, all_queues)
|
||||||
return 'Deleted', 204
|
return 'Deleted', 204
|
||||||
|
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|
|
@ -10,7 +10,7 @@ from peewee import IntegrityError
|
||||||
|
|
||||||
import features
|
import features
|
||||||
|
|
||||||
from app import app, billing as stripe, authentication, avatar, user_analytics
|
from app import app, billing as stripe, authentication, avatar, user_analytics, all_queues
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
from auth.permissions import (AdministerOrganizationPermission, CreateRepositoryPermission,
|
||||||
|
@ -346,9 +346,11 @@ class User(ApiResource):
|
||||||
@validate_json_request('NewUser')
|
@validate_json_request('NewUser')
|
||||||
def post(self):
|
def post(self):
|
||||||
""" Create a new user. """
|
""" Create a new user. """
|
||||||
|
if app.config['AUTHENTICATION_TYPE'] != 'Database':
|
||||||
|
abort(404)
|
||||||
|
|
||||||
user_data = request.get_json()
|
user_data = request.get_json()
|
||||||
invite_code = user_data.get('invite_code', '')
|
invite_code = user_data.get('invite_code', '')
|
||||||
|
|
||||||
existing_user = model.user.get_nonrobot_user(user_data['username'])
|
existing_user = model.user.get_nonrobot_user(user_data['username'])
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise request_error(message='The username already exists')
|
raise request_error(message='The username already exists')
|
||||||
|
@ -373,6 +375,19 @@ class User(ApiResource):
|
||||||
except model.user.DataModelException as ex:
|
except model.user.DataModelException as ex:
|
||||||
raise request_error(exception=ex)
|
raise request_error(exception=ex)
|
||||||
|
|
||||||
|
@require_user_admin
|
||||||
|
@require_fresh_login
|
||||||
|
@nickname('deleteCurrentUser')
|
||||||
|
@internal_only
|
||||||
|
def delete(self):
|
||||||
|
""" Deletes the current user. """
|
||||||
|
if app.config['AUTHENTICATION_TYPE'] != 'Database':
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
model.user.delete_user(get_authenticated_user(), all_queues)
|
||||||
|
return 'Deleted', 204
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/user/private')
|
@resource('/v1/user/private')
|
||||||
@internal_only
|
@internal_only
|
||||||
@show_if(features.BILLING)
|
@show_if(features.BILLING)
|
||||||
|
|
|
@ -1651,3 +1651,12 @@ a:focus {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cor-confirm-dialog-element .modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cor-confirm-dialog-element .progress-message {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
3
static/css/directives/ui/delete-namespace-view.css
Normal file
3
static/css/directives/ui/delete-namespace-view.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.delete-namespace-view-element .yellow {
|
||||||
|
color: #FCA657;
|
||||||
|
}
|
|
@ -8,13 +8,19 @@
|
||||||
<h4 class="modal-title">{{ dialogTitle }}</h4>
|
<h4 class="modal-title">{{ dialogTitle }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" ng-show="working">
|
<div class="modal-body" ng-show="working">
|
||||||
<div class="cor-loader"></div>
|
<div class="cor-loader" ng-if="!dialogContext.progress"></div>
|
||||||
|
<div class="progress-message" ng-if="dialogContext.progressMessage">
|
||||||
|
{{ dialogContext.progressMessage }}
|
||||||
|
</div>
|
||||||
|
<div class="cor-progress-bar" ng-if="dialogContext.progress" progress="dialogContext.progress">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body" ng-show="!working">
|
<div class="modal-body" ng-show="!working">
|
||||||
<span ng-transclude/>
|
<span ng-transclude/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" ng-show="!working">
|
<div class="modal-footer" ng-show="!working">
|
||||||
<button type="button" class="btn btn-primary" ng-click="performAction()" ng-disabled="dialogForm && dialogForm.$invalid">
|
<button type="button" class="btn btn-primary" ng-class="dialogButtonClass || 'btn-primary'"
|
||||||
|
ng-click="performAction()" ng-disabled="dialogForm && dialogForm.$invalid">
|
||||||
{{ dialogActionTitle }}
|
{{ dialogActionTitle }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
|
|
4
static/directives/cor-progress-bar.html
Normal file
4
static/directives/cor-progress-bar.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="cor-progress-bar-element progress">
|
||||||
|
<div class="progress-bar" ng-style="{'width': (progress * 100) + '%'}"
|
||||||
|
aria-valuenow="{{ (progress * 100) }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||||
|
</div>
|
35
static/directives/delete-namespace-view.html
Normal file
35
static/directives/delete-namespace-view.html
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<div class="delete-namespace-view-element" quay-show="!Features.BILLING || subscriptionStatus != 'loading'">
|
||||||
|
<table class="co-list-table">
|
||||||
|
<tr>
|
||||||
|
<td>Delete Account:</td>
|
||||||
|
<td quay-show="!Features.BILLING || subscriptionStatus == 'none'">
|
||||||
|
<a class="co-modify-link" ng-click="showDeleteNamespace()">Begin deletion</a>
|
||||||
|
</td>
|
||||||
|
<td quay-show="Features.BILLING && subscriptionStatus == 'valid'">
|
||||||
|
<i class="fa fa-exclamation-triangle yellow"></i> You must cancel your billing subscription before this account can be deleted.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Delete account dialog -->
|
||||||
|
<div class="cor-confirm-dialog"
|
||||||
|
dialog-context="deleteNamespaceInfo"
|
||||||
|
dialog-action="deleteNamespace(info, callback)"
|
||||||
|
dialog-title="Delete Account"
|
||||||
|
dialog-action-title="Delete Account"
|
||||||
|
dialog-form="context.deleteform"
|
||||||
|
dialog-button-class="btn-danger">
|
||||||
|
<form name="context.deleteform" class="co-single-field-dialog">
|
||||||
|
<div class="co-alert co-alert-danger">
|
||||||
|
Deleting an account is <strong>non-reversable</strong> and will delete
|
||||||
|
<strong>all of the account's data</strong> including repositories, created build triggers,
|
||||||
|
and notifications.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
You must type <code>{{ deleteNamespaceInfo.namespace }}</code> below to confirm deletion is requested:
|
||||||
|
<input type="text" class="form-control" placeholder="Enter namespace here"
|
||||||
|
ng-model="deleteNamespaceInfo.verification" ng-pattern="deleteNamespaceInfo.namespace"
|
||||||
|
required>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -173,6 +173,7 @@ angular.module("core-ui", [])
|
||||||
'dialogTitle': '@dialogTitle',
|
'dialogTitle': '@dialogTitle',
|
||||||
'dialogActionTitle': '@dialogActionTitle',
|
'dialogActionTitle': '@dialogActionTitle',
|
||||||
'dialogForm': '=dialogForm',
|
'dialogForm': '=dialogForm',
|
||||||
|
'dialogButtonClass': '@dialogButtonClass',
|
||||||
|
|
||||||
'dialogContext': '=dialogContext',
|
'dialogContext': '=dialogContext',
|
||||||
'dialogAction': '&dialogAction'
|
'dialogAction': '&dialogAction'
|
||||||
|
@ -614,6 +615,22 @@ angular.module("core-ui", [])
|
||||||
return directiveDefinitionObject;
|
return directiveDefinitionObject;
|
||||||
})
|
})
|
||||||
|
|
||||||
|
.directive('corProgressBar', function() {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 4,
|
||||||
|
templateUrl: '/static/directives/cor-progress-bar.html',
|
||||||
|
replace: true,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'progress': '=progress'
|
||||||
|
},
|
||||||
|
controller: function($rootScope, $scope, $element) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
})
|
||||||
|
|
||||||
.directive('corStepBar', function() {
|
.directive('corStepBar', function() {
|
||||||
var directiveDefinitionObject = {
|
var directiveDefinitionObject = {
|
||||||
priority: 4,
|
priority: 4,
|
||||||
|
|
|
@ -11,7 +11,8 @@ angular.module('quay').directive('billingManagementPanel', function () {
|
||||||
scope: {
|
scope: {
|
||||||
'user': '=user',
|
'user': '=user',
|
||||||
'organization': '=organization',
|
'organization': '=organization',
|
||||||
'isEnabled': '=isEnabled'
|
'isEnabled': '=isEnabled',
|
||||||
|
'subscriptionStatus': '=subscriptionStatus'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, PlanService, ApiService, Features) {
|
controller: function($scope, $element, PlanService, ApiService, Features) {
|
||||||
$scope.currentCard = null;
|
$scope.currentCard = null;
|
||||||
|
@ -19,6 +20,7 @@ angular.module('quay').directive('billingManagementPanel', function () {
|
||||||
$scope.updating = true;
|
$scope.updating = true;
|
||||||
$scope.changeReceiptsInfo = null;
|
$scope.changeReceiptsInfo = null;
|
||||||
$scope.context = {};
|
$scope.context = {};
|
||||||
|
$scope.subscriptionStatus = 'loading';
|
||||||
|
|
||||||
var setSubscription = function(sub) {
|
var setSubscription = function(sub) {
|
||||||
$scope.subscription = sub;
|
$scope.subscription = sub;
|
||||||
|
@ -29,12 +31,14 @@ angular.module('quay').directive('billingManagementPanel', function () {
|
||||||
|
|
||||||
if (!sub.hasSubscription) {
|
if (!sub.hasSubscription) {
|
||||||
$scope.updating = false;
|
$scope.updating = false;
|
||||||
|
$scope.subscriptionStatus = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load credit card information.
|
// Load credit card information.
|
||||||
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
|
PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) {
|
||||||
$scope.currentCard = card;
|
$scope.currentCard = card;
|
||||||
|
$scope.subscriptionStatus = 'valid';
|
||||||
$scope.updating = false;
|
$scope.updating = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
34
static/js/directives/ui/delete-namespace-view.js
Normal file
34
static/js/directives/ui/delete-namespace-view.js
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* An element which displays a settings table row for deleting a namespace (user or organization).
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('deleteNamespaceView', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/delete-namespace-view.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'user': '=user',
|
||||||
|
'organization': '=organization',
|
||||||
|
'subscriptionStatus': '=subscriptionStatus'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element, UserService) {
|
||||||
|
$scope.context = {};
|
||||||
|
|
||||||
|
$scope.showDeleteNamespace = function() {
|
||||||
|
$scope.deleteNamespaceInfo = {
|
||||||
|
'user': $scope.user,
|
||||||
|
'organization': $scope.organization,
|
||||||
|
'namespace': $scope.user ? $scope.user.username : $scope.organization.name,
|
||||||
|
'verification': ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.deleteNamespace = function(info, callback) {
|
||||||
|
UserService.deleteNamespace(info, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -20,6 +20,7 @@
|
||||||
$scope.showRobotsCounter = 0;
|
$scope.showRobotsCounter = 0;
|
||||||
$scope.showTeamsCounter = 0;
|
$scope.showTeamsCounter = 0;
|
||||||
$scope.changeEmailInfo = null;
|
$scope.changeEmailInfo = null;
|
||||||
|
$scope.context = {};
|
||||||
|
|
||||||
$scope.orgScope = {
|
$scope.orgScope = {
|
||||||
'changingOrganization': false,
|
'changingOrganization': false,
|
||||||
|
|
|
@ -3,9 +3,9 @@
|
||||||
* about the user.
|
* about the user.
|
||||||
*/
|
*/
|
||||||
angular.module('quay')
|
angular.module('quay')
|
||||||
.factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config',
|
.factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', '$location',
|
||||||
|
|
||||||
function(ApiService, CookieService, $rootScope, Config) {
|
function(ApiService, CookieService, $rootScope, Config, $location) {
|
||||||
var userResponse = {
|
var userResponse = {
|
||||||
verified: false,
|
verified: false,
|
||||||
anonymous: true,
|
anonymous: true,
|
||||||
|
@ -169,6 +169,69 @@ function(ApiService, CookieService, $rootScope, Config) {
|
||||||
return externalUsername || userResponse.username;
|
return externalUsername || userResponse.username;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
userService.deleteNamespace = function(info, callback) {
|
||||||
|
var namespace = info.user ? info.user.username : info.organization.name;
|
||||||
|
var deleteNamespaceItself = function() {
|
||||||
|
info.progress = 1;
|
||||||
|
info.progressMessage = 'Deleting namespace...';
|
||||||
|
|
||||||
|
if (info.user) {
|
||||||
|
ApiService.deleteCurrentUser().then(function(resp) {
|
||||||
|
// Reload the user.
|
||||||
|
userService.load();
|
||||||
|
callback(true);
|
||||||
|
$location.path('/');
|
||||||
|
}, errorDisplay);
|
||||||
|
} else {
|
||||||
|
var delParams = {
|
||||||
|
'name': info.organization.name
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.deleteOrganization(null, delParams).then(function(resp) {
|
||||||
|
// Reload the user.
|
||||||
|
userService.load();
|
||||||
|
callback(true);
|
||||||
|
$location.path('/');
|
||||||
|
}, errorDisplay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var repoIndex = 0;
|
||||||
|
var repositories = null;
|
||||||
|
var deleteAllRepos = function() {
|
||||||
|
if (repoIndex >= repositories.length) {
|
||||||
|
deleteNamespaceItself();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var repoParams = {
|
||||||
|
'repository': namespace + '/' + repositories[repoIndex]['name']
|
||||||
|
};
|
||||||
|
|
||||||
|
info.progress = repoIndex / repositories.length;
|
||||||
|
info.progressMessage = 'Deleting repository ' + repoParams['repository'] + '...';
|
||||||
|
|
||||||
|
ApiService.deleteRepository(null, repoParams).then(function() {
|
||||||
|
repoIndex++;
|
||||||
|
deleteAllRepos();
|
||||||
|
}, errorDisplay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// First delete each repo for the namespace, updating the info so it can show a progress bar.
|
||||||
|
// This is not strictly necessary (as the namespace delete call will do it as well), but it is
|
||||||
|
// a better user experience.
|
||||||
|
var params = {
|
||||||
|
'namespace': namespace,
|
||||||
|
'public': false
|
||||||
|
};
|
||||||
|
|
||||||
|
var errorDisplay = ApiService.errorDisplay('Could not delete namespace', callback);
|
||||||
|
ApiService.listRepos(null, params).then(function(resp) {
|
||||||
|
repositories = resp['repositories'];
|
||||||
|
deleteAllRepos();
|
||||||
|
}, errorDisplay);
|
||||||
|
};
|
||||||
|
|
||||||
userService.currentUser = function() {
|
userService.currentUser = function() {
|
||||||
return userResponse;
|
return userResponse;
|
||||||
};
|
};
|
||||||
|
|
|
@ -116,12 +116,14 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div class="delete-namespace-view" subscription-status="subscriptionStatus" organization="organization"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Billing Information -->
|
<!-- Billing Information -->
|
||||||
<div class="settings-section" quay-show="Features.BILLING">
|
<div class="settings-section" quay-show="Features.BILLING">
|
||||||
<h3>Billing Information</h3>
|
<h3>Billing Information</h3>
|
||||||
<div class="billing-management-panel" organization="organization" is-enabled="showBillingCounter"></div>
|
<div class="billing-management-panel" organization="organization" is-enabled="showBillingCounter" subscription-status="subscriptionStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -129,7 +131,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Change email dialog -->
|
<!-- Change email dialog -->
|
||||||
<div class="cor-confirm-dialog"
|
<div class="cor-confirm-dialog"
|
||||||
dialog-context="changeEmailInfo"
|
dialog-context="changeEmailInfo"
|
||||||
|
|
|
@ -133,12 +133,15 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<div class="delete-namespace-view" subscription-status="subscriptionStatus" user="context.viewuser"
|
||||||
|
quay-show="Config.AUTHENTICATION_TYPE == 'Database'"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Billing Information -->
|
<!-- Billing Information -->
|
||||||
<div class="settings-section" quay-show="Features.BILLING">
|
<div class="settings-section" quay-show="Features.BILLING">
|
||||||
<h3>Billing Information</h3>
|
<h3>Billing Information</h3>
|
||||||
<div class="billing-management-panel" user="context.viewuser" is-enabled="showBillingCounter"></div>
|
<div class="billing-management-panel" user="context.viewuser" is-enabled="showBillingCounter" subscription-status="subscriptionStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div> <!-- /cor-tab-content -->
|
</div> <!-- /cor-tab-content -->
|
||||||
|
|
|
@ -21,7 +21,7 @@ from mockldap import MockLdap
|
||||||
from endpoints.api import api_bp, api
|
from endpoints.api import api_bp, api
|
||||||
from endpoints.building import PreparedBuild
|
from endpoints.building import PreparedBuild
|
||||||
from endpoints.webhooks import webhooks
|
from endpoints.webhooks import webhooks
|
||||||
from app import app, config_provider, notification_queue
|
from app import app, config_provider, all_queues, dockerfile_build_queue, notification_queue
|
||||||
from buildtrigger.basehandler import BuildTriggerHandler
|
from buildtrigger.basehandler import BuildTriggerHandler
|
||||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from data import database, model
|
from data import database, model
|
||||||
|
@ -581,7 +581,6 @@ class TestCreateNewUser(ApiTestCase):
|
||||||
teamname='owners'))
|
teamname='owners'))
|
||||||
self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
|
self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
|
||||||
|
|
||||||
|
|
||||||
def test_createuser_withteaminvite_differentemails(self):
|
def test_createuser_withteaminvite_differentemails(self):
|
||||||
inviter = model.user.get_user(ADMIN_ACCESS_USER)
|
inviter = model.user.get_user(ADMIN_ACCESS_USER)
|
||||||
team = model.team.get_organization_team(ORGANIZATION, 'owners')
|
team = model.team.get_organization_team(ORGANIZATION, 'owners')
|
||||||
|
@ -606,6 +605,32 @@ class TestCreateNewUser(ApiTestCase):
|
||||||
self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
|
self.assertNotInTeam(json, NEW_USER_DETAILS['username'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteNamespace(ApiTestCase):
|
||||||
|
def test_deletenamespaces(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Try to first delete the user. Since they are the sole admin of two orgs, it should fail.
|
||||||
|
with check_transitive_deletes():
|
||||||
|
self.deleteResponse(User, expected_code=400)
|
||||||
|
|
||||||
|
# Delete the two orgs, checking in between.
|
||||||
|
with check_transitive_deletes():
|
||||||
|
self.deleteResponse(Organization, params=dict(orgname=ORGANIZATION), expected_code=204)
|
||||||
|
self.deleteResponse(User, expected_code=400) # Should still fail.
|
||||||
|
self.deleteResponse(Organization, params=dict(orgname='library'), expected_code=204)
|
||||||
|
|
||||||
|
# Add some queue items for the user.
|
||||||
|
notification_queue.put([ADMIN_ACCESS_USER, 'somerepo', 'somename'], '{}')
|
||||||
|
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'anotherrepo'], '{}')
|
||||||
|
|
||||||
|
# Now delete the user.
|
||||||
|
with check_transitive_deletes():
|
||||||
|
self.deleteResponse(User, expected_code=204)
|
||||||
|
|
||||||
|
# Ensure the queue items are gone.
|
||||||
|
self.assertIsNone(notification_queue.get())
|
||||||
|
self.assertIsNone(dockerfile_build_queue.get())
|
||||||
|
|
||||||
|
|
||||||
class TestSignin(ApiTestCase):
|
class TestSignin(ApiTestCase):
|
||||||
def test_signin_unicode(self):
|
def test_signin_unicode(self):
|
||||||
|
@ -1798,13 +1823,37 @@ class TestDeleteRepository(ApiTestCase):
|
||||||
self.getResponse(Repository,
|
self.getResponse(Repository,
|
||||||
params=dict(repository=self.SIMPLE_REPO))
|
params=dict(repository=self.SIMPLE_REPO))
|
||||||
|
|
||||||
|
# Add a build queue item for the repo.
|
||||||
|
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'simple'], '{}')
|
||||||
|
|
||||||
|
# Delete the repository.
|
||||||
self.deleteResponse(Repository, params=dict(repository=self.SIMPLE_REPO))
|
self.deleteResponse(Repository, params=dict(repository=self.SIMPLE_REPO))
|
||||||
|
|
||||||
|
# Ensure the queue item is gone.
|
||||||
|
self.assertIsNone(dockerfile_build_queue.get())
|
||||||
|
|
||||||
# Verify the repo was deleted.
|
# Verify the repo was deleted.
|
||||||
self.getResponse(Repository,
|
self.getResponse(Repository,
|
||||||
params=dict(repository=self.SIMPLE_REPO),
|
params=dict(repository=self.SIMPLE_REPO),
|
||||||
expected_code=404)
|
expected_code=404)
|
||||||
|
|
||||||
|
def test_verify_queue_removal(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Verify the repo exists.
|
||||||
|
self.getResponse(Repository,
|
||||||
|
params=dict(repository=self.SIMPLE_REPO))
|
||||||
|
|
||||||
|
# Add a build queue item for the repo and another repo.
|
||||||
|
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'simple'], '{}')
|
||||||
|
dockerfile_build_queue.put([ADMIN_ACCESS_USER, 'anotherrepo'], '{}')
|
||||||
|
|
||||||
|
# Delete the repository.
|
||||||
|
self.deleteResponse(Repository, params=dict(repository=self.SIMPLE_REPO))
|
||||||
|
|
||||||
|
# Ensure the other queue item is still present.
|
||||||
|
self.assertIsNotNone(dockerfile_build_queue.get())
|
||||||
|
|
||||||
def test_deleterepo2(self):
|
def test_deleterepo2(self):
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
@ -3717,7 +3766,7 @@ class TestSuperUserCreateInitialSuperUser(ApiTestCase):
|
||||||
|
|
||||||
# Delete all users in the DB.
|
# Delete all users in the DB.
|
||||||
for user in list(database.User.select()):
|
for user in list(database.User.select()):
|
||||||
user.delete_instance(recursive=True)
|
model.user.delete_user(user, all_queues, force=True)
|
||||||
|
|
||||||
# Create the superuser.
|
# Create the superuser.
|
||||||
self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data)
|
self.postJsonResponse(SuperUserCreateInitialSuperUser, data=data)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from test.test_api_usage import ApiTestCase, READ_ACCESS_USER, ADMIN_ACCESS_USER
|
from test.test_api_usage import ApiTestCase, READ_ACCESS_USER, ADMIN_ACCESS_USER
|
||||||
from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile,
|
from endpoints.api.suconfig import (SuperUserRegistryStatus, SuperUserConfig, SuperUserConfigFile,
|
||||||
SuperUserCreateInitialSuperUser, SuperUserConfigValidate)
|
SuperUserCreateInitialSuperUser, SuperUserConfigValidate)
|
||||||
from app import config_provider
|
from app import config_provider, all_queues
|
||||||
from data.database import User
|
from data.database import User
|
||||||
|
from data import model
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
@ -95,7 +96,7 @@ class TestSuperUserCreateInitialSuperUser(ApiTestCase):
|
||||||
|
|
||||||
# Delete all the users in the DB.
|
# Delete all the users in the DB.
|
||||||
for user in list(User.select()):
|
for user in list(User.select()):
|
||||||
user.delete_instance(recursive=True)
|
model.user.delete_user(user, all_queues, force=True)
|
||||||
|
|
||||||
# This method should now succeed.
|
# This method should now succeed.
|
||||||
data = dict(username='cooluser', password='password', email='fake@example.com')
|
data = dict(username='cooluser', password='password', email='fake@example.com')
|
||||||
|
|
Reference in a new issue