diff --git a/app.py b/app.py index a8b701c66..b2f41cd69 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,7 @@ from data.users import UserAuthentication from util.analytics import Analytics from util.exceptionlog import Sentry from util.queuemetrics import QueueMetrics +from util.expiration import Expiration from data.billing import Billing from data.buildlogs import BuildLogs from data.queue import WorkQueue @@ -64,6 +65,7 @@ sentry = Sentry(app) build_logs = BuildLogs(app) queue_metrics = QueueMetrics(app) authentication = UserAuthentication(app) +expiration = Expiration(app) tf = app.config['DB_TRANSACTION_FACTORY'] image_diff_queue = WorkQueue(app.config['DIFFS_QUEUE_NAME'], tf) diff --git a/data/database.py b/data/database.py index 71f88fb91..ffa8c909e 100644 --- a/data/database.py +++ b/data/database.py @@ -117,7 +117,7 @@ class FederatedLogin(BaseModel): class Visibility(BaseModel): - name = CharField(index=True) + name = CharField(index=True, unique=True) class Repository(BaseModel): @@ -136,7 +136,7 @@ class Repository(BaseModel): class Role(BaseModel): - name = CharField(index=True) + name = CharField(index=True, unique=True) class RepositoryPermission(BaseModel): @@ -189,7 +189,7 @@ class AccessToken(BaseModel): class BuildTriggerService(BaseModel): - name = CharField(index=True) + name = CharField(index=True, unique=True) class RepositoryBuildTrigger(BaseModel): @@ -283,7 +283,7 @@ class QueueItem(BaseModel): class LogEntryKind(BaseModel): - name = CharField(index=True) + name = CharField(index=True, unique=True) class LogEntry(BaseModel): @@ -330,7 +330,7 @@ class OAuthAccessToken(BaseModel): class NotificationKind(BaseModel): - name = CharField(index=True) + name = CharField(index=True, unique=True) class Notification(BaseModel): diff --git a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py index 3c4a8de5d..23aaf506a 100644 --- a/data/migrations/versions/5a07499ce53f_set_up_initial_database.py +++ b/data/migrations/versions/5a07499ce53f_set_up_initial_database.py @@ -140,6 +140,7 @@ def upgrade(): [ {'id':1, 'name':'password_required'}, {'id':2, 'name':'over_private_usage'}, + {'id':3, 'name':'expiring_license'}, ]) op.create_table('teamrole', diff --git a/data/model/legacy.py b/data/model/legacy.py index 76d0123be..f27e29170 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1622,14 +1622,20 @@ def list_trigger_builds(namespace_name, repository_name, trigger_uuid, .where(RepositoryBuildTrigger.uuid == trigger_uuid)) -def create_notification(kind, target, metadata={}): - kind_ref = NotificationKind.get(name=kind) +def create_notification(kind_name, target, metadata={}): + kind_ref = NotificationKind.get(name=kind_name) notification = Notification.create(kind=kind_ref, target=target, metadata_json=json.dumps(metadata)) return notification -def list_notifications(user, kind=None): +def create_unique_notification(kind_name, target, metadata={}): + with config.app_config['DB_TRANSACTION_FACTORY'](db): + if list_notifications(target, kind_name).count() == 0: + create_notification(kind_name, target, metadata) + + +def list_notifications(user, kind_name=None): Org = User.alias() AdminTeam = Team.alias() AdminTeamMember = TeamMember.alias() @@ -1647,20 +1653,30 @@ def list_notifications(user, kind=None): .join(AdminTeamMember, JOIN_LEFT_OUTER, on=(AdminTeam.id == AdminTeamMember.team)) .join(AdminUser, JOIN_LEFT_OUTER, on=(AdminTeamMember.user == - AdminUser.id))) + AdminUser.id)) + .where((Notification.target == user) | + ((AdminUser.id == user) & (TeamRole.name == 'admin'))) + .order_by(Notification.created) + .desc()) - where_clause = ((Notification.target == user) | - ((AdminUser.id == user) & - (TeamRole.name == 'admin'))) - - if kind: - where_clause = where_clause & (NotificationKind.name == kind) + if kind_name: + query = (query + .switch(Notification) + .join(NotificationKind) + .where(NotificationKind.name == kind_name)) - return query.where(where_clause).order_by(Notification.created).desc() + return query -def delete_notifications_by_kind(target, kind): - kind_ref = NotificationKind.get(name=kind) +def delete_all_notifications_by_kind(kind_name): + kind_ref = NotificationKind.get(name=kind_name) + (Notification.delete() + .where(Notification.kind == kind_ref) + .execute()) + + +def delete_notifications_by_kind(target, kind_name): + kind_ref = NotificationKind.get(name=kind_name) Notification.delete().where(Notification.target == target, Notification.kind == kind_ref).execute() diff --git a/initdb.py b/initdb.py index d3b1daf09..33d2f048e 100644 --- a/initdb.py +++ b/initdb.py @@ -233,6 +233,7 @@ def initialize_database(): NotificationKind.create(name='password_required') NotificationKind.create(name='over_private_usage') + NotificationKind.create(name='expiring_license') NotificationKind.create(name='test_notification') diff --git a/static/js/app.js b/static/js/app.js index 75220520c..e41c06a74 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -913,6 +913,12 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading return '/user'; } } + }, + 'expiring_license': { + 'level': 'error', + 'message': 'Your license will expire at: {expires_at} ' + + '

Please contact Quay.io support to purchase a new license.', + 'page': '/contact/' } }; diff --git a/test/data/test.db b/test/data/test.db index 8b3830464..801d3a28a 100644 Binary files a/test/data/test.db and b/test/data/test.db differ diff --git a/util/expiration.py b/util/expiration.py new file mode 100644 index 000000000..b3f5732fe --- /dev/null +++ b/util/expiration.py @@ -0,0 +1,76 @@ +import calendar +import sys + +from email.utils import formatdate +from apscheduler.scheduler import Scheduler +from datetime import datetime, timedelta + +from data import model + + +class ExpirationScheduler(object): + def __init__(self, utc_create_notifications_date, utc_terminate_processes_date): + self._scheduler = Scheduler() + self._termination_date = utc_terminate_processes_date + + soon = datetime.now() + timedelta(seconds=1) + + if utc_create_notifications_date > datetime.utcnow(): + self._scheduler.add_date_job(model.delete_all_notifications_by_kind, soon, + ['expiring_license']) + + local_notifications_date = self._utc_to_local(utc_create_notifications_date) + self._scheduler.add_date_job(self._generate_notifications, local_notifications_date) + else: + self._scheduler.add_date_job(self._generate_notifications, soon) + + local_termination_date = self._utc_to_local(utc_terminate_processes_date) + self._scheduler.add_date_job(self._terminate, local_termination_date) + + @staticmethod + def _format_date(date): + """ Output an RFC822 date format. """ + if date is None: + return None + return formatdate(calendar.timegm(date.utctimetuple())) + + @staticmethod + def _utc_to_local(utc_dt): + # get integer timestamp to avoid precision lost + timestamp = calendar.timegm(utc_dt.timetuple()) + local_dt = datetime.fromtimestamp(timestamp) + return local_dt.replace(microsecond=utc_dt.microsecond) + + def _generate_notifications(self): + for user in model.get_active_users(): + model.create_unique_notification('expiring_license', user, + {'expires_at': self._format_date(self._termination_date)}) + + @staticmethod + def _terminate(): + sys.exit(1) + + def start(self): + self._scheduler.start() + + +class Expiration(object): + def __init__(self, app=None): + self.app = app + if app is not None: + self.state = self.init_app(app) + else: + self.state = None + + def init_app(self, app): + expiration = ExpirationScheduler(app.config['LICENSE_EXPIRATION_WARNING'], + app.config['LICENSE_EXPIRATION']) + expiration.start() + + # register extension with app + app.extensions = getattr(app, 'extensions', {}) + app.extensions['expiration'] = expiration + return expiration + + def __getattr__(self, name): + return getattr(self.state, name, None)