Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/js/src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export default function Settings() {
Username: <Username username={name} hideLink elementType="span" />
</h4>
<a
href={`https://musicbrainz.org/user/${name}`}
href={`https://musicbrainz.org/user/${encodeURIComponent(name)}`}
aria-label="Edit Profile on MusicBrainz"
title="Edit Profile on MusicBrainz"
className="btn btn-outline-info"
Expand Down Expand Up @@ -133,4 +133,4 @@ export default function Settings() {
</div>
</>
);
}
}
2 changes: 1 addition & 1 deletion listenbrainz/background/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path

import orjson
from listenbrainz.domain.notification_sender import send_notification
from listenbrainz.domain.metabrainz_notifications import send_notification
from dateutil.relativedelta import relativedelta
from flask import current_app, render_template
from sqlalchemy import text
Expand Down
2 changes: 1 addition & 1 deletion listenbrainz/db/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from typing import Tuple, List

from flask import current_app, render_template
from listenbrainz.domain.notification_sender import send_notification
from listenbrainz.domain.metabrainz_notifications import send_notification


logger = logging.getLogger(__name__)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from requests.auth import HTTPBasicAuth
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
import requests
from brainzutils import cache
from flask import current_app
import requests

from oauthlib.oauth2 import BackendApplicationClient
from requests.auth import HTTPBasicAuth
from requests_oauthlib import OAuth2Session

TOKEN_CACHE_KEY = "notification_access_token"
METABRAINZ_NOTIFICATIONS_SEND_URL = "https://metabrainz.org/notification/send"
METABRAINZ_NOTIFICATIONS_ENDPOINT = "https://metabrainz.org/notification"


def send_notification(
Expand Down Expand Up @@ -70,13 +69,65 @@ def send_multiple_notifications(notifications: list[dict]):

token = _fetch_token()
headers = {"Authorization": f"Bearer {token}"}
notification_send_endpoint = METABRAINZ_NOTIFICATIONS_ENDPOINT + "/send"

response = requests.post(
url=METABRAINZ_NOTIFICATIONS_SEND_URL, json=notifications, headers=headers
)
response = requests.post(url=notification_send_endpoint, json=notifications, headers=headers)
response.raise_for_status()


def get_digest_preference(musicbrainz_row_id: int) -> dict:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docstrings

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, sorry. Got lost in branches. Added it now.

"""Retrieves the current digest preference of a user.

Args:
``musicbrainz_row_id`` (int)

Returns:
A dict containing
``digest`` (bool): Whether digest is enabled for the user.
``digest_age`` (int): The digest_age set for the user.

Raises:
A HTTPError if there's a failure.

"""
digest_endpoint = METABRAINZ_NOTIFICATIONS_ENDPOINT + f"/{musicbrainz_row_id}/digest-preference"
token = _fetch_token()
headers = {"Authorization": f"Bearer {token}"}

response = requests.get(url=digest_endpoint, headers=headers)
response.raise_for_status()

return response.json()


def set_digest_preference(musicbrainz_row_id: int, digest: bool, digest_age: int = None) -> dict:
"""Sets the digest preference for a user.

Args:
``musicbrainz_row_id`` (int)
``digest`` (bool): Whether digest should be enabled.
``digest_age`` (int): The age in days for the digest. If set to None, MeB server defaults it to 7 days.

Returns:
A dict containing
``digest`` (bool): Whether digest is enabled for the user.
``digest_age`` (int): The digest age set for the user.

Raises:
A HTTPError if there's a failure.

"""
digest_endpoint = METABRAINZ_NOTIFICATIONS_ENDPOINT + f"/{musicbrainz_row_id}/digest-preference"
token = _fetch_token()
headers = {"Authorization": f"Bearer {token}"}
data = {"digest": digest, "digest_age": digest_age}

response = requests.post(url=digest_endpoint, json=data, headers=headers)
response.raise_for_status()

return response.json()


def _fetch_token() -> str:
"""Helper function to fetch OAuth2 token from redis cache, If no token is found or it's expired, a new token is requested."""

Expand All @@ -90,9 +141,7 @@ def _fetch_token() -> str:

client = BackendApplicationClient(client_id=client_id, scope="notification")
oauth = OAuth2Session(client=client)
token = oauth.fetch_token(
token_url=token_url, auth=HTTPBasicAuth(client_id, client_secret)
)
token = oauth.fetch_token(token_url=token_url, auth=HTTPBasicAuth(client_id, client_secret))
access_token = token["access_token"]
expires_in = token["expires_in"]

Expand Down
89 changes: 89 additions & 0 deletions listenbrainz/domain/tests/test_metabrainz_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from unittest.mock import patch, MagicMock
from listenbrainz.domain import metabrainz_notifications
from listenbrainz.tests.integration import NonAPIIntegrationTestCase


class MetabrainNotificationsTestCase(NonAPIIntegrationTestCase):
@patch("listenbrainz.domain.metabrainz_notifications.send_multiple_notifications")
def test_send_notification(self, mock_send_multiple):
metabrainz_notifications.send_notification(
subject="test123",
body="testbody456",
musicbrainz_row_id=123,
user_email="[email protected]",
from_addr="[email protected]",
)
expected_notification = [
[
{
"subject": "test123",
"body": "testbody456",
"user_id": 123,
"to": "[email protected]",
"project": "listenbrainz",
"sent_from": "[email protected]",
"send_email": True,
"important": True,
"expire_age": 7,
}
]
]

mock_send_multiple.assert_called_once_with(expected_notification)

@patch("listenbrainz.domain.metabrainz_notifications._fetch_token")
@patch("requests.post")
def test_send_multiple_notifications(self, mock_post, mock_fetch_token):
mock_fetch_token.return_value = "access_token"
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_post.return_value = mock_response

notifications = [{"user_id": 1, "body": "Notification 1"}]

metabrainz_notifications.send_multiple_notifications(notifications)

mock_fetch_token.assert_called_once()
expected_url = "https://metabrainz.org/notification/send"
expected_headers = {"Authorization": "Bearer access_token"}
mock_post.assert_called_once_with(
url=expected_url, json=notifications, headers=expected_headers
)
mock_response.raise_for_status.assert_called_once()

@patch("listenbrainz.domain.metabrainz_notifications._fetch_token")
@patch("requests.get")
def test_get_digest_preference(self, mock_get, mock_fetch_token):
mock_fetch_token.return_value = "access_token"
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"digest": True, "digest_age": 7}
mock_get.return_value = mock_response

result = metabrainz_notifications.get_digest_preference(musicbrainz_row_id=456)

expected_url = "https://metabrainz.org/notification/456/digest-preference"
mock_get.assert_called_once_with(
url=expected_url, headers={"Authorization": "Bearer access_token"}
)
self.assertEqual(result, {"digest": True, "digest_age": 7})

@patch("listenbrainz.domain.metabrainz_notifications._fetch_token")
@patch("requests.post")
def test_set_digest_preference(self, mock_post, mock_fetch_token):
mock_fetch_token.return_value = "access_token"
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = {"digest": True, "digest_age": 14}
mock_post.return_value = mock_response

result = metabrainz_notifications.set_digest_preference(
musicbrainz_row_id=789, digest=True, digest_age=14
)

expected_url = "https://metabrainz.org/notification/789/digest-preference"
expected_data = {"digest": True, "digest_age": 14}
mock_post.assert_called_once_with(
url=expected_url, json=expected_data, headers={"Authorization": "Bearer access_token"}
)
self.assertEqual(result, {"digest": True, "digest_age": 14})
2 changes: 1 addition & 1 deletion listenbrainz/listens_importer/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.exceptions import InternalServerError, ServiceUnavailable

from listenbrainz.domain.notification_sender import send_notification
from listenbrainz.domain.metabrainz_notifications import send_notification

from listenbrainz.db.exceptions import DatabaseException
from listenbrainz.domain.external_service import ExternalServiceError
Expand Down
26 changes: 24 additions & 2 deletions listenbrainz/webserver/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import listenbrainz.db.user_setting as db_usersetting
from data.model.external_service import ExternalServiceType
from listenbrainz.background.background_tasks import add_task
from listenbrainz.db import listens_importer
from listenbrainz.db.exceptions import DatabaseException
from listenbrainz.db.missing_musicbrainz_data import get_user_missing_musicbrainz_data
from listenbrainz.domain.apple import AppleService
Expand All @@ -24,12 +23,12 @@
from listenbrainz.domain.musicbrainz import MusicBrainzService
from listenbrainz.domain.soundcloud import SoundCloudService
from listenbrainz.domain.spotify import SpotifyService, SPOTIFY_LISTEN_PERMISSIONS, SPOTIFY_IMPORT_PERMISSIONS
from listenbrainz.domain.metabrainz_notifications import get_digest_preference, set_digest_preference
from listenbrainz.webserver import db_conn, ts_conn
from listenbrainz.webserver.decorators import web_listenstore_needed
from listenbrainz.webserver.errors import APIServiceUnavailable, APINotFound, APIForbidden, APIInternalServerError, \
APIBadRequest
from listenbrainz.webserver.login import api_login_required
from data.model.external_service import ExternalServiceType


settings_bp = Blueprint("settings", __name__)
Expand Down Expand Up @@ -376,6 +375,29 @@ def link_listens():
return jsonify(data)


@settings_bp.post("/notifications/")
@api_login_required
def get_digest_setting():
"""Returns the current digest setting of the user."""
user = db_user.get(db_conn, current_user.id)
data = get_digest_preference(user["musicbrainz_row_id"])

return jsonify(data)


@settings_bp.post("/set-notification-settings/")
@api_login_required
def set_digest_setting():
"""Sets the digest preference for the user."""
user = db_user.get(db_conn, current_user.id)
data = request.json
resp_data = set_digest_preference(user["musicbrainz_row_id"], data["digest"], data["digest_age"])
if resp_data == data or (data["digest_age"] is None and data["digest"] == resp_data["digest"]):
return jsonify({"success": True})
else:
raise APIBadRequest("API request data doesn't match the response data.")


@settings_bp.get('/', defaults={'path': ''})
@settings_bp.get('/<path:path>/')
@login_required
Expand Down
Loading