diff --git a/seqr/views/apis/feature_updates_api.py b/seqr/views/apis/feature_updates_api.py index 2fccead30e..140b650182 100644 --- a/seqr/views/apis/feature_updates_api.py +++ b/seqr/views/apis/feature_updates_api.py @@ -11,18 +11,20 @@ TIMEOUT = 5 +def fetch_feature_updates(): + response = requests.get(FEED_URL, timeout=TIMEOUT) + response.raise_for_status() + feed = parse(response.content) + return feed.entries + + def get_feature_updates(request): """ Fetches the feature-updates GitHub Discussion Atom feed, converts feed entries into markdown, and returns markdown and information for each feed entry. """ - response = requests.get(FEED_URL, timeout=TIMEOUT) - response.raise_for_status() - - feed = parse(response.content) - entries = [] - for entry in feed.entries: + for entry in fetch_feature_updates(): # Atom feeds can have multiple content elements per feed entry markdown = "".join(md(content.value) for content in entry.content) entries.append( diff --git a/seqr/views/react_app.py b/seqr/views/react_app.py index 0b40591a39..23c9992f44 100644 --- a/seqr/views/react_app.py +++ b/seqr/views/react_app.py @@ -5,6 +5,8 @@ from django.middleware.csrf import rotate_token from django.template import loader from django.http import HttpResponse +import logging + from settings import ( SEQR_VERSION, CSRF_COOKIE_NAME, @@ -15,9 +17,13 @@ VLM_CLIENT_ID, ) from seqr.models import WarningMessage +from seqr.utils.redis_utils import safe_redis_get_json, safe_redis_set_json +from seqr.views.apis.feature_updates_api import fetch_feature_updates from seqr.views.utils.orm_to_json_utils import get_json_for_user, get_json_for_current_user from seqr.views.utils.permissions_utils import login_active_required +logger = logging.getLogger(__name__) + @login_active_required(login_url=LOGIN_URL) def main_app(request, *args, **kwargs): @@ -56,6 +62,7 @@ def render_app_html(request, additional_json=None, include_user=True, status=200 'oauthLoginProvider': SOCIAL_AUTH_PROVIDER, 'vlmEnabled': bool(VLM_CLIENT_ID), 'warningMessages': [message.json() for message in WarningMessage.objects.all()], + 'lastFeatureUpdate': _get_latest_feature_update_date(), }} if include_user: initial_json['user'] = get_json_for_current_user(request.user) @@ -89,3 +96,18 @@ def render_app_html(request, additional_json=None, include_user=True, status=200 ) return HttpResponse(html, content_type="text/html", status=status) + + +FEATURE_CACHE_KEY = 'feature_updates_latest_date' + +def _get_latest_feature_update_date(): + update_date = safe_redis_get_json(FEATURE_CACHE_KEY) + if not update_date: + try: + entries = fetch_feature_updates() + except Exception as e: + logger.error(f'Unable to fetch feature update date: {e}') + return None + update_date = entries[0].published + safe_redis_set_json(FEATURE_CACHE_KEY, update_date, expire=60*60*3) + return update_date diff --git a/seqr/views/react_app_tests.py b/seqr/views/react_app_tests.py index f363edcb09..09d177e82a 100644 --- a/seqr/views/react_app_tests.py +++ b/seqr/views/react_app_tests.py @@ -1,19 +1,81 @@ -from datetime import datetime from django.urls.base import reverse import mock +import responses from seqr.views.react_app import main_app, no_login_main_app from seqr.views.utils.terra_api_utils import TerraRefreshTokenFailedException from seqr.views.utils.test_utils import TEST_OAUTH2_PROVIDER, AuthenticationTestCase, AnvilAuthenticationTestCase, USER_FIELDS MOCK_GA_TOKEN = 'mock_ga_token' # nosec +FEATURE_UPDATE_FEED = """ + + + tag:github.com,2008:/broadinstitute/seqr/discussions/categories/feature-updates + + + Recent discussions in broadinstitute/seqr, category: feature-updates + 2023-11-08T15:11:40+00:00 + + tag:github.com,2008:5828412 + + Welcome to Feature Updates + 2023-11-08T15:11:31+00:00 + 2023-11-08T15:11:40+00:00 + + + hanars + https://github.com/hanars + + + <p dir="auto">Welcome to the seqr feature update discussion channel! This channel will be used for all announcements of new seqr functionality</p> + + + <h2 dir="auto">Welcome to our discussion forum!</h2> + <p dir="auto">We\xe2\x80\x99re using Discussions as a place to connect members of the seqr community with our development team and with one another. We hope that you:</p> + <ul dir="auto"> + <li>Ask questions</li> + <li>Describe issues that may be user errors instead of bugs</li> + <li>Answer and engage with one another</li> + <li>Discuss ideas for enhancements</li> + </ul> + + + + tag:github.com,2008:3913526 + + [ACTION REQUIRED] Upcoming Deprecation of older docker images and static JS/CSS assets + + 2022-03-03T16:25:56+00:00 + 2022-06-23T13:32:55+00:00 + + + sjahl + https://github.com/sjahl + + + <h3 dir="auto">What is happening?</h3> + <h4 dir="auto">Summary</h4> + <p dir="auto">Seqr is making updates to how our docker images work, and you will need to update your deployment by 2022/04/04. Please see the information below for more details.</p> + <h4 dir="auto">Slightly Longer Version</h4> + <p dir="auto">We\xe2\x80\x99re making two functional changes to the way seqr is packaged in Docker.</p> + <ol dir="auto"> + <li>We will be removing the statically built javascript/CSS/HTML assets that are checked into git. These will now be generated in the docker image when it\xe2\x80\x99s built, rather than provided in the source code tree.</li> + <li>New docker images will be built every time we release seqr. These images will no longer <code class="notranslate">git pull</code> to update themselves on startup.</li> + </ol> + <p dir="auto">However, if you are using a docker image from prior to when we made these changes, your installation still has the <code class="notranslate">git pull</code> update functionality. If you do nothing, your seqr installation may break after 2022/04/04 when it pulls down the version of the seqr code that has the assets removed. You will find instructions below on how to proceed.</p> + + + +""" + @mock.patch('seqr.views.react_app.DEBUG', False) +@mock.patch('seqr.utils.redis_utils.redis.StrictRedis') class AppPageTest(object): databases = ['default'] fixtures = ['users'] - def _check_page_html(self, response, user, user_key='user', vlm_enabled=False, user_email=None, user_fields=None, ga_token_id=None): + def _check_page_html(self, response, user, user_key='user', vlm_enabled=False, user_email=None, user_fields=None, ga_token_id=None, last_feature_update=None): user_fields = user_fields or USER_FIELDS self.assertEqual(response.status_code, 200) initial_json = self.get_initial_page_json(response) @@ -26,6 +88,7 @@ def _check_page_html(self, response, user, user_key='user', vlm_enabled=False, 'oauthLoginProvider': self.OAUTH_PROVIDER, 'vlmEnabled': vlm_enabled, 'warningMessages': [{'id': 1, 'header': 'Warning!', 'message': 'A sample warning'}], + 'lastFeatureUpdate': last_feature_update, }) self.assertEqual(self.get_initial_page_window('gaTrackingId', response), ga_token_id) @@ -39,14 +102,24 @@ def _check_page_html(self, response, user, user_key='user', vlm_enabled=False, @mock.patch('seqr.views.react_app.VLM_CLIENT_ID', 'abc123') @mock.patch('seqr.views.react_app.GA_TOKEN_ID', MOCK_GA_TOKEN) - def test_react_page(self): + @responses.activate + def test_react_page(self, mock_redis): url = reverse(main_app) self.check_require_login_no_policies(url, login_redirect_url='/login') + responses.add( + responses.GET, + 'https://github.com/broadinstitute/seqr/discussions/categories/feature-updates.atom', + body=FEATURE_UPDATE_FEED, + ) response = self.client.get(url) - self._check_page_html(response, 'test_user_no_policies', user_email='test_user_no_policy@test.com', ga_token_id=MOCK_GA_TOKEN, vlm_enabled=True) + self._check_page_html(response, 'test_user_no_policies', user_email='test_user_no_policy@test.com', ga_token_id=MOCK_GA_TOKEN, vlm_enabled=True, last_feature_update='2023-11-08T15:11:31+00:00') - def test_local_react_page(self): + mock_redis.return_value.set.assert_called_with('feature_updates_latest_date', '"2023-11-08T15:11:31+00:00"') + mock_redis.return_value.expire.assert_called_with('feature_updates_latest_date', 10800) + + @responses.activate + def test_local_react_page(self, mock_redis): url = reverse(no_login_main_app) response = self.client.get(url, HTTP_HOST='localhost:3000') self.assertEqual(response.status_code, 200) @@ -56,7 +129,8 @@ def test_local_react_page(self): self.assertContains(response, 'src="/app.js"') self.assertNotRegex(content, r']*>') - def test_no_login_react_page(self): + @responses.activate + def test_no_login_react_page(self, mock_redis): url = reverse(no_login_main_app) response = self.client.get(url) @@ -81,12 +155,15 @@ def test_no_login_react_page(self): response = self.client.get(url) self._check_page_html(response, 'test_user') - def test_react_page_additional_configs(self): + @responses.activate + def test_react_page_additional_configs(self, mock_redis): url = reverse(main_app) self.check_require_login_no_policies(url, login_redirect_url='/login') + mock_redis.return_value.get.return_value = '"2025-11-18T00:00:00Z"' + response = self.client.get(url) - self._check_page_html(response, 'test_user_no_policies') + self._check_page_html(response, 'test_user_no_policies', last_feature_update='2025-11-18T00:00:00Z') class LocalAppPageTest(AuthenticationTestCase, AppPageTest): diff --git a/ui/redux/selectors.js b/ui/redux/selectors.js index 3fe2aace45..93918f9f33 100644 --- a/ui/redux/selectors.js +++ b/ui/redux/selectors.js @@ -41,6 +41,7 @@ export const getOauthLoginProvider = state => state.meta.oauthLoginProvider export const getVlmEnabled = state => state.meta.vlmEnabled export const getHijakEnabled = state => state.meta.hijakEnabled export const getWarningMessages = state => state.meta.warningMessages +export const getLastFeatureUpdate = state => state.meta.lastFeatureUpdate export const getSavedVariantsIsLoading = state => state.savedVariantsLoading.isLoading export const getSavedVariantsLoadingError = state => state.savedVariantsLoading.errorMessage export const getSearchesByHash = state => state.searchesByHash diff --git a/ui/shared/components/page/Header.jsx b/ui/shared/components/page/Header.jsx index f56839d732..93ee0326db 100644 --- a/ui/shared/components/page/Header.jsx +++ b/ui/shared/components/page/Header.jsx @@ -2,12 +2,12 @@ import React from 'react' import PropTypes from 'prop-types' import styled from 'styled-components' -import { Menu, Header, Dropdown } from 'semantic-ui-react' +import { Menu, Header, Dropdown, Label } from 'semantic-ui-react' import { connect } from 'react-redux' import { Link } from 'react-router-dom' import { updateUser } from 'redux/rootReducer' -import { getUser, getOauthLoginProvider } from 'redux/selectors' +import { getUser, getOauthLoginProvider, getLastFeatureUpdate } from 'redux/selectors' import { USER_NAME_FIELDS, LOCAL_LOGIN_URL, FEATURE_UPDATES_PATH } from 'shared/utils/constants' import UpdateButton from '../buttons/UpdateButton' @@ -18,7 +18,7 @@ const HeaderMenu = styled(Menu)` padding-right: 100px; ` -const PageHeader = React.memo(({ user, oauthLoginProvider, onSubmit }) => { +const PageHeader = React.memo(({ user, oauthLoginProvider, onSubmit, lastFeatureUpdate }) => { const loginUrl = oauthLoginProvider ? `/login/${oauthLoginProvider}` : LOCAL_LOGIN_URL return ( @@ -34,7 +34,11 @@ const PageHeader = React.memo(({ user, oauthLoginProvider, onSubmit }) => { , ] : null } - + + Feature Updates + {(lastFeatureUpdate && (new Date()).setMonth(new Date().getMonth() - 1) < new Date(lastFeatureUpdate)) && + } + {Object.keys(user).length ? [ ({ user: getUser(state), oauthLoginProvider: getOauthLoginProvider(state), + lastFeatureUpdate: getLastFeatureUpdate(state), }) const mapDispatchToProps = {