Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Donation Leaderboard Page #2981

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
84 changes: 84 additions & 0 deletions frontend/css/donors-page.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
.donor-card {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid #e2e8f0;
padding: 1rem;
margin: 1.5rem 0;
@media (min-width: 640px) {
flex-direction: row;
align-items: center;
}

.donor-info {
display: flex;
margin-bottom: 1rem;
flex-direction: column;

@media (min-width: 640px) {
margin-bottom: 0;
}

.donation-user {
font-weight: 600;
font-size: 1.75rem;
margin-bottom: 0.5rem;
.donor-name {
font-weight: 300 !important;
&:hover,
&:visited {
color: #353070 !important;
}
}
}

.donation-date {
display: flex;
color: #6b7280;
gap: 10px;
align-items: baseline;
}
}

.donor-stats {
display: flex;
flex-direction: column;
align-items: flex-end;

.donation-amount {
font-weight: 600;
font-size: 1.75rem;
margin-bottom: 0.5rem;
}

.recent-listens {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;

.listen-item {
display: flex;
align-items: center;
color: #6b7280;
background-color: #edf2f7;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
gap: 0.5rem;
font-weight: 300;

&:hover,
&:visited {
color: #6b7280 !important;
}
}
}
}

&:hover {
background-color: @table-bg-hover;
}
}

#donors {
margin-top: 1.5em;
}
1 change: 1 addition & 0 deletions frontend/css/main.less
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
@import "release-card.less";
@import "search.less";
@import "accordion.less";
@import "donors-page.less";

@icon-font-path: "/static/fonts/";

Expand Down
3 changes: 3 additions & 0 deletions frontend/js/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ function Navbar() {
<NavLink to="/about/" onClick={toggleSidebar}>
About
</NavLink>
<NavLink to="/donors/" onClick={toggleSidebar}>
Donors
</NavLink>
<a
href="https://community.metabrainz.org/c/listenbrainz"
target="_blank"
Expand Down
159 changes: 159 additions & 0 deletions frontend/js/src/donors/Donors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import * as React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCalendar,
faListAlt,
faMusic,
} from "@fortawesome/free-solid-svg-icons";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Pill from "../components/Pill";
import { formatListenCount } from "../explore/fresh-releases/utils";
import Pagination from "../common/Pagination";
import { getObjectForURLSearchParams } from "../utils/utils";
import { RouteQuery } from "../utils/Loader";
import Loader from "../components/Loader";

type DonorLoaderData = {
data: {
id: number;
donated_at: string;
donation: number;
currency: "usd" | "eur";
musicbrainz_id: string;
is_listenbrainz_user: boolean;
listenCount: number;
playlistCount: number;
}[];
totalPageCount: number;
};

function Donors() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const searchParamsObj = getObjectForURLSearchParams(searchParams);
const currPageNoStr = searchParams.get("page") || "1";
const currPageNo = parseInt(currPageNoStr, 10);
const sort = searchParams.get("sort") || "date";

const { data, isLoading } = useQuery<DonorLoaderData>(
RouteQuery(
["donors", currPageNoStr, sort],
`${location.pathname}${location.search}`
)
);

const { data: donors, totalPageCount = 1 } = data || {};

const handleClickPrevious = () => {
setSearchParams({
...searchParamsObj,
page: Math.max(currPageNo - 1, 1).toString(),
});
};

const handleClickNext = () => {
setSearchParams({
...searchParamsObj,
page: Math.min(currPageNo + 1, totalPageCount).toString(),
});
};

const handleSortBy = (sortBy: string) => {
if (sortBy === sort) {
return;
}
setSearchParams({
page: "1",
sort: sortBy,
});
};

return (
<div role="main" id="donors">
<div className="listen-header">
<h2 className="header-with-line">Donations</h2>
<div className="flex" role="group" aria-label="Sort by">
<Pill
type="secondary"
active={sort === "date"}
onClick={() => handleSortBy("date")}
>
Date
</Pill>
<Pill
type="secondary"
active={sort === "amount"}
onClick={() => handleSortBy("amount")}
>
Amount
</Pill>
</div>
</div>
<Loader isLoading={isLoading}>
{donors?.map((donor) => (
<div key={donor.id} className="donor-card">
<div className="donor-info">
<div className="donation-user">
{donor.musicbrainz_id &&
(donor.is_listenbrainz_user ? (
<Link
to={`/user/${donor.musicbrainz_id}`}
className="donor-name"
>
{donor.musicbrainz_id}
</Link>
) : (
<span>{donor.musicbrainz_id}</span>
))}
</div>
<div className="donation-date">
<FontAwesomeIcon icon={faCalendar} />
<span>
Donation Date:{" "}
{new Date(donor.donated_at).toLocaleDateString()}
</span>
</div>
</div>
<div className="donor-stats">
<p className="donation-amount">
{donor.currency === "usd" ? "$" : "€"}
{donor.donation}
</p>
{donor.musicbrainz_id && donor.is_listenbrainz_user ? (
<div className="recent-listens">
{donor.listenCount ? (
<Link
className="listen-item"
to={`/user/${donor.musicbrainz_id}/stats/?range=all_time`}
>
<FontAwesomeIcon icon={faMusic} />
{formatListenCount(donor.listenCount)} Listens
</Link>
) : null}
{donor.playlistCount ? (
<Link
className="listen-item"
to={`/user/${donor.musicbrainz_id}/playlists/`}
>
<FontAwesomeIcon icon={faListAlt} />
{formatListenCount(donor.playlistCount)} Playlists
</Link>
) : null}
</div>
) : null}
</div>
</div>
))}
</Loader>
<Pagination
currentPageNo={currPageNo}
totalPageCount={totalPageCount}
handleClickPrevious={handleClickPrevious}
handleClickNext={handleClickNext}
/>
</div>
);
}

export default Donors;
7 changes: 7 additions & 0 deletions frontend/js/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ const getIndexRoutes = (): RouteObject[] => {
return { Component: APIAuth.default };
},
},
{
path: "donors/",
lazy: async () => {
const Donors = await import("../donors/Donors");
return { Component: Donors.default };
},
},
],
},
];
Expand Down
36 changes: 34 additions & 2 deletions listenbrainz/db/donation.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,18 @@ def get_recent_donors(meb_conn, db_conn, count: int, offset: int):
})
donors = results.all()

return get_flairs_for_donors(db_conn, donors)
total_count_query = """
SELECT COUNT(*)
FROM payment
WHERE editor_id IS NOT NULL
AND is_donation = 't'
AND payment_date >= (NOW() - INTERVAL '1 year')
"""

result = meb_conn.execute(text(total_count_query))
total_count = result.scalar()

return get_flairs_for_donors(db_conn, donors), total_count


def get_biggest_donors(meb_conn, db_conn, count: int, offset: int):
Expand Down Expand Up @@ -154,7 +165,28 @@ def get_biggest_donors(meb_conn, db_conn, count: int, offset: int):
})
donors = results.all()

return get_flairs_for_donors(db_conn, donors)
total_count_query = """
WITH select_donations AS (
SELECT editor_id
, currency
FROM payment
WHERE editor_id IS NOT NULL
AND is_donation = 't'
AND payment_date >= (NOW() - INTERVAL '1 year')
)
SELECT COUNT(*)
FROM (
SELECT editor_id
, currency
FROM select_donations
GROUP BY editor_id, currency
) AS total_count;
"""

result = meb_conn.execute(text(total_count_query))
total_count = result.scalar()

return get_flairs_for_donors(db_conn, donors), total_count


def is_user_eligible_donor(meb_conn, musicbrainz_row_id: int):
Expand Down
11 changes: 11 additions & 0 deletions listenbrainz/db/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -983,3 +983,14 @@ def get_playlist_recordings_metadata(mb_curs, ts_curs, playlist: Playlist) -> Pl
rec.additional_metadata = additional_metadata

return playlist


def get_playlist_count(ts_conn, creator_ids: List[str]) -> dict:
query = text("""
SELECT creator_id, COUNT(*) as count
FROM playlist.playlist
WHERE creator_id IN :creator_ids
GROUP BY creator_id
""")
result = ts_conn.execute(query, {"creator_ids": tuple(creator_ids)})
return {row[0]: row[1] for row in result.fetchall()}
25 changes: 25 additions & 0 deletions listenbrainz/listenstore/timescale_listenstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,31 @@ def get_listen_count_for_user(self, user_id: int):
cache.set(REDIS_USER_LISTEN_COUNT + str(user_id), count, REDIS_USER_LISTEN_COUNT_EXPIRY)
return count

def get_listen_count_for_users(self, user_ids: list):
"""Get the total number of listens for a list of users.

Args:
user_ids: the list of users to get listens for
"""
cached_count_map = cache.get_many([REDIS_USER_LISTEN_COUNT + str(user_id) for user_id in user_ids])
# Extract the user_ids for which we don't have cached counts. cached_cout is a dict of key-value pairs
# where key is the cache key and value is the cached value. We need to extract the user_id from the cache key.
listen_count = {int(key.split(".")[1]): value for key, value in cached_count_map.items()
if value is not None}
missing_user_ids = set(user_ids) - set(listen_count.keys())

if not missing_user_ids:
return listen_count

query = "SELECT user_id, count, created FROM listen_user_metadata WHERE user_id = ANY(:user_ids)"
result = ts_conn.execute(sqlalchemy.text(query), {"user_ids": list(missing_user_ids)})
data = result.fetchall()
listen_count.update({row.user_id: row.count for row in data})
cache.set_many({REDIS_USER_LISTEN_COUNT + str(row.user_id): row.count for row in data},
expirein=REDIS_USER_LISTEN_COUNT_EXPIRY)
return listen_count


def get_timestamps_for_user(self, user_id: int) -> Tuple[Optional[datetime], Optional[datetime]]:
""" Return the min_ts and max_ts for the given list of users """
query = """
Expand Down
3 changes: 3 additions & 0 deletions listenbrainz/webserver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ def _register_blueprints(app):
from listenbrainz.webserver.views.explore import explore_bp
app.register_blueprint(explore_bp, url_prefix='/explore')

from listenbrainz.webserver.views.donors import donors_bp
app.register_blueprint(donors_bp, url_prefix='/donors')

from listenbrainz.webserver.views.api import api_bp
app.register_blueprint(api_bp, url_prefix=API_PREFIX)

Expand Down
4 changes: 2 additions & 2 deletions listenbrainz/webserver/views/donor_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def recent_donors():
count = _parse_int_arg("count", DEFAULT_DONOR_COUNT)
offset = _parse_int_arg("offset", 0)

donors = get_recent_donors(meb_conn, db_conn, count, offset)
donors, _ = get_recent_donors(meb_conn, db_conn, count, offset)
return jsonify(donors)


Expand All @@ -36,5 +36,5 @@ def biggest_donors():
count = _parse_int_arg("count", DEFAULT_DONOR_COUNT)
offset = _parse_int_arg("offset", 0)

donors = get_biggest_donors(meb_conn, db_conn, count, offset)
donors, _ = get_biggest_donors(meb_conn, db_conn, count, offset)
return jsonify(donors)
Loading