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 = {