diff --git a/data/database.py b/data/database.py
index 413d2261c..a00078018 100644
--- a/data/database.py
+++ b/data/database.py
@@ -271,9 +271,22 @@ class LogEntry(BaseModel):
metadata_json = TextField(default='{}')
+class NotificationKind(BaseModel):
+ name = CharField(index=True)
+
+
+class Notification(BaseModel):
+ uuid = CharField(default=uuid_generator, index=True)
+ kind = ForeignKeyField(NotificationKind, index=True)
+ target = ForeignKeyField(User, index=True)
+ metadata_json = TextField(default='{}')
+ created = DateTimeField(default=datetime.now, index=True)
+
+
all_models = [User, Repository, Image, AccessToken, Role,
RepositoryPermission, Visibility, RepositoryTag,
EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole, Webhook,
LogEntryKind, LogEntry, PermissionPrototype, ImageStorage,
- BuildTriggerService, RepositoryBuildTrigger]
+ BuildTriggerService, RepositoryBuildTrigger, NotificationKind,
+ Notification]
diff --git a/data/model.py b/data/model.py
index fd3b51855..b47706703 100644
--- a/data/model.py
+++ b/data/model.py
@@ -59,7 +59,7 @@ class InvalidBuildTriggerException(DataModelException):
pass
-def create_user(username, password, email):
+def create_user(username, password, email, is_organization=False):
if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email)
if not validate_username(username):
@@ -93,6 +93,12 @@ def create_user(username, password, email):
new_user = User.create(username=username, password_hash=pw_hash,
email=email)
+
+ # If the password is None, then add a notification for the user to change
+ # their password ASAP.
+ if not pw_hash and not is_organization:
+ create_notification('password_required', new_user)
+
return new_user
except Exception as ex:
raise DataModelException(ex.message)
@@ -101,7 +107,7 @@ def create_user(username, password, email):
def create_organization(name, email, creating_user):
try:
# Create the org
- new_org = create_user(name, None, email)
+ new_org = create_user(name, None, email, is_organization=True)
new_org.organization = True
new_org.save()
@@ -662,6 +668,9 @@ def change_password(user, new_password):
user.password_hash = pw_hash
user.save()
+ # Remove any password required notifications for the user.
+ delete_notifications_by_kind(user, 'password_required')
+
def change_invoice_email(user, invoice_email):
user.invoice_email = invoice_email
@@ -1535,3 +1544,46 @@ def list_trigger_builds(namespace_name, repository_name, trigger_uuid,
limit):
return (list_repository_builds(namespace_name, repository_name, limit)
.where(RepositoryBuildTrigger.uuid == trigger_uuid))
+
+
+def create_notification(kind, target, metadata={}):
+ kind_ref = NotificationKind.get(name=kind)
+ notification = Notification.create(kind=kind_ref, target=target,
+ metadata_json=json.dumps(metadata))
+ return notification
+
+
+def list_notifications(user, kind=None):
+ Org = User.alias()
+ AdminTeam = Team.alias()
+ AdminTeamMember = TeamMember.alias()
+ AdminUser = User.alias()
+
+ query = (Notification.select()
+ .join(User)
+
+ .switch(Notification)
+ .join(Org, JOIN_LEFT_OUTER, on=(Org.id == Notification.target))
+ .join(AdminTeam, JOIN_LEFT_OUTER, on=(Org.id ==
+ AdminTeam.organization))
+ .join(TeamRole, JOIN_LEFT_OUTER, on=(AdminTeam.role == TeamRole.id))
+ .switch(AdminTeam)
+ .join(AdminTeamMember, JOIN_LEFT_OUTER, on=(AdminTeam.id ==
+ AdminTeamMember.team))
+ .join(AdminUser, JOIN_LEFT_OUTER, on=(AdminTeamMember.user ==
+ AdminUser.id)))
+
+ where_clause = ((Notification.target == user) |
+ ((AdminUser.id == user) &
+ (TeamRole.name == 'admin')))
+
+ if kind:
+ where_clause = where_clause & (NotificationKind.name == kind)
+
+ return query.where(where_clause).order_by(Notification.created).desc()
+
+
+def delete_notifications_by_kind(target, kind):
+ kind_ref = NotificationKind.get(name=kind)
+ Notification.delete().where(Notification.target == target,
+ Notification.kind == kind_ref).execute()
diff --git a/endpoints/api.py b/endpoints/api.py
index 22a571be4..5c86e8710 100644
--- a/endpoints/api.py
+++ b/endpoints/api.py
@@ -28,7 +28,7 @@ from auth.permissions import (ReadRepositoryPermission,
ViewTeamPermission,
UserPermission)
from endpoints.common import (common_login, get_route_data, truthy_param,
- start_build)
+ start_build, check_repository_usage)
from endpoints.trigger import (BuildTrigger, TriggerActivationException,
TriggerDeactivationException,
EmptyRepositoryException)
@@ -2197,6 +2197,7 @@ def subscribe(user, plan, token, require_business_plan):
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
user.stripe_id = cus.id
user.save()
+ check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
except stripe.CardError as e:
return carderror_response(e)
@@ -2213,6 +2214,7 @@ def subscribe(user, plan, token, require_business_plan):
# We only have to cancel the subscription if they actually have one
cus.cancel_subscription()
cus.save()
+ check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
else:
@@ -2228,6 +2230,7 @@ def subscribe(user, plan, token, require_business_plan):
return carderror_response(e)
response_json = subscription_view(cus.subscription, private_repos)
+ check_repository_usage(user, plan_found)
log_action('account_change_plan', user.username, {'plan': plan})
resp = jsonify(response_json)
@@ -2518,3 +2521,20 @@ def get_logs(namespace, start_time, end_time, performer_name=None,
'logs': [log_view(log) for log in logs]
})
+
+def notification_view(notification):
+ return {
+ 'organization': notification.target.username if notification.target.organization else None,
+ 'kind': notification.kind.name,
+ 'created': notification.created,
+ 'metadata': json.loads(notification.metadata_json),
+ }
+
+
+@api.route('/user/notifications', methods=['GET'])
+@api_login_required
+def list_user_notifications():
+ notifications = model.list_notifications(current_user.db_user())
+ return jsonify({
+ 'notifications': [notification_view(notification) for notification in notifications]
+ })
diff --git a/endpoints/common.py b/endpoints/common.py
index 0fc3dc3da..064d29461 100644
--- a/endpoints/common.py
+++ b/endpoints/common.py
@@ -5,7 +5,7 @@ import urlparse
import json
from flask import session, make_response, render_template, request
-from flask.ext.login import login_user, UserMixin
+from flask.ext.login import login_user, UserMixin, current_user
from flask.ext.principal import identity_changed
from data import model
@@ -120,13 +120,22 @@ app.jinja_env.globals['csrf_token'] = generate_csrf_token
def render_page_template(name, **kwargs):
-
resp = make_response(render_template(name, route_data=get_route_data(),
**kwargs))
resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp
+def check_repository_usage(user_or_org, plan_found):
+ private_repos = model.get_private_repo_count(user_or_org.username)
+ repos_allowed = plan_found['privateRepos']
+
+ if private_repos > repos_allowed:
+ model.create_notification('over_private_usage', user_or_org, {'namespace': user_or_org.username})
+ else:
+ model.delete_notifications_by_kind(user_or_org, 'over_private_usage')
+
+
def start_build(repository, dockerfile_id, tags, build_name, subdir, manual,
trigger=None):
host = urlparse.urlparse(request.url).netloc
diff --git a/endpoints/trigger.py b/endpoints/trigger.py
index 41c32045a..82a3284ab 100644
--- a/endpoints/trigger.py
+++ b/endpoints/trigger.py
@@ -203,7 +203,7 @@ class GithubBuildTrigger(BuildTrigger):
try:
repo = gh_client.get_repo(source)
- default_commit = repo.get_branch(repo.master_branch).commit
+ default_commit = repo.get_branch(repo.master_branch or 'master').commit
commit_tree = repo.get_git_tree(default_commit.sha, recursive=True)
return [os.path.dirname(elem.path) for elem in commit_tree.tree
@@ -283,4 +283,4 @@ class GithubBuildTrigger(BuildTrigger):
short_sha = GithubBuildTrigger.get_display_name(master_sha)
ref = 'refs/heads/%s' % repo.master_branch
- return self._prepare_build(config, repo, master_sha, short_sha, ref)
\ No newline at end of file
+ return self._prepare_build(config, repo, master_sha, short_sha, ref)
diff --git a/initdb.py b/initdb.py
index a1e2fe646..78f694f67 100644
--- a/initdb.py
+++ b/initdb.py
@@ -224,6 +224,11 @@ def initialize_database():
LogEntryKind.create(name='setup_repo_trigger')
LogEntryKind.create(name='delete_repo_trigger')
+ NotificationKind.create(name='password_required')
+ NotificationKind.create(name='over_private_usage')
+
+ NotificationKind.create(name='test_notification')
+
def wipe_database():
logger.debug('Wiping all data from the DB.')
@@ -261,6 +266,9 @@ def populate_database():
new_user_4.verified = True
new_user_4.save()
+ new_user_5 = model.create_user('unverified', 'password', 'no5@thanks.com')
+ new_user_5.save()
+
reader = model.create_user('reader', 'password', 'no1@thanks.com')
reader.verified = True
reader.save()
@@ -269,6 +277,8 @@ def populate_database():
outside_org.verified = True
outside_org.save()
+ model.create_notification('test_notification', new_user_1, metadata={'some': 'value'})
+
__generate_repository(new_user_4, 'randomrepo', 'Random repo repository.', False,
[], (4, [], ['latest', 'prod']))
diff --git a/static/css/quay.css b/static/css/quay.css
index ec1b17447..02ccb2584 100644
--- a/static/css/quay.css
+++ b/static/css/quay.css
@@ -9,6 +9,57 @@
}
}
+.notification-view-element {
+ cursor: pointer;
+ margin-bottom: 10px;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 10px;
+ position: relative;
+ max-width: 320px;
+}
+
+.notification-view-element .orginfo {
+ margin-top: 8px;
+ float: left;
+}
+
+.notification-view-element .orginfo .orgname {
+ font-size: 12px;
+ color: #aaa;
+}
+
+.notification-view-element .circle {
+ position: absolute;
+ top: 14px;
+ left: 0px;
+
+ width: 12px;
+ height: 12px;
+ display: inline-block;
+ border-radius: 50%;
+}
+
+.notification-view-element .datetime {
+ margin-top: 16px;
+ font-size: 12px;
+ color: #aaa;
+ text-align: right;
+}
+
+.notification-view-element .message {
+ margin-bottom: 4px;
+}
+
+.notification-view-element .container {
+ padding: 10px;
+ border-radius: 6px;
+ margin-left: 16px;
+}
+
+.notification-view-element .container:hover {
+ background: rgba(66, 139, 202, 0.1);
+}
+
.dockerfile-path {
margin-top: 10px;
padding: 20px;
@@ -507,7 +558,22 @@ i.toggle-icon:hover {
min-width: 200px;
}
-.user-notification {
+.notification-primary {
+ background: #428bca;
+ color: white;
+}
+
+.notification-info {
+ color: black;
+ background: #d9edf7;
+}
+
+.notification-warning {
+ color: #8a6d3b;
+ background: #fcf8e3;
+}
+
+.notification-error {
background: red;
}
@@ -2131,16 +2197,16 @@ p.editable:hover i {
padding-right: 6px;
}
-.delete-ui {
+.delete-ui-element {
outline: none;
}
-.delete-ui i {
+.delete-ui-element i {
cursor: pointer;
vertical-align: middle;
}
-.delete-ui .delete-ui-button {
+.delete-ui-element .delete-ui-button {
display: inline-block;
vertical-align: middle;
color: white;
@@ -2156,15 +2222,15 @@ p.editable:hover i {
transition: width 500ms ease-in-out;
}
-.delete-ui .delete-ui-button button {
+.delete-ui-element .delete-ui-button button {
padding: 4px;
}
-.delete-ui:focus i {
+.delete-ui-element:focus i {
visibility: hidden;
}
-.delete-ui:focus .delete-ui-button {
+.delete-ui-element:focus .delete-ui-button {
width: 60px;
}
diff --git a/static/directives/delete-ui.html b/static/directives/delete-ui.html
new file mode 100644
index 000000000..d04e840f0
--- /dev/null
+++ b/static/directives/delete-ui.html
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/static/directives/header-bar.html b/static/directives/header-bar.html
index 21b42cbcb..6d31cf951 100644
--- a/static/directives/header-bar.html
+++ b/static/directives/header-bar.html
@@ -39,11 +39,14 @@
{{ user.username }}
-
- {{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
+ {{ notificationService.notifications.length }}
@@ -51,8 +54,16 @@
Account Settings
-
- {{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
+
+
+
+
+ Notifications
+
+ {{ notificationService.notifications.length }}
diff --git a/static/directives/notification-bar.html b/static/directives/notification-bar.html
new file mode 100644
index 000000000..5d25a40b4
--- /dev/null
+++ b/static/directives/notification-bar.html
@@ -0,0 +1,15 @@
+
diff --git a/static/directives/notification-view.html b/static/directives/notification-view.html
new file mode 100644
index 000000000..6327a5df8
--- /dev/null
+++ b/static/directives/notification-view.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
 }}?s=24&d=identicon)
+
{{ notification.organization }}
+
+
{{ parseDate(notification.created) | date:'medium'}}
+
+
diff --git a/static/directives/popup-input-button.html b/static/directives/popup-input-button.html
index 005c037bc..4ff6dce77 100644
--- a/static/directives/popup-input-button.html
+++ b/static/directives/popup-input-button.html
@@ -1,5 +1,6 @@
-