Bulk update operations on tracks

This commit is contained in:
Paul Bienkowski 2022-09-22 20:09:54 +02:00
parent cbab83e6e3
commit 4fe7d45dec
2 changed files with 213 additions and 93 deletions

View file

@ -3,7 +3,7 @@ import re
from json import load as jsonload from json import load as jsonload
from os.path import join, exists, isfile from os.path import join, exists, isfile
from sqlalchemy import select, func from sqlalchemy import select, func, and_
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from obs.api.db import Track, User, Comment, DuplicateTrackFileError from obs.api.db import Track, User, Comment, DuplicateTrackFileError
@ -128,6 +128,39 @@ async def get_feed(req):
return await _return_tracks(req, extend_query, limit, offset, order_by) return await _return_tracks(req, extend_query, limit, offset, order_by)
@api.post("/tracks/bulk")
@require_auth
async def tracks_bulk_action(req):
body = req.json
action = body["action"]
track_slugs = body["tracks"]
if action not in ("delete", "makePublic", "makePrivate", "reprocess"):
raise InvalidUsage("invalid action")
query = select(Track).where(
and_(Track.author_id == req.ctx.user.id, Track.slug.in_(track_slugs))
)
for track in (await req.ctx.db.execute(query)).scalars():
if action == "delete":
await req.ctx.db.delete(track)
elif action == "makePublic":
if not track.public:
track.queue_processing()
track.public = True
elif action == "makePrivate":
if track.public:
track.queue_processing()
track.public = False
elif action == "reprocess":
track.queue_processing()
await req.ctx.db.commit()
return empty()
@api.post("/tracks") @api.post("/tracks")
@read_api_key @read_api_key
@require_auth @require_auth

View file

@ -1,8 +1,10 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { import {
Accordion, Accordion,
Button, Button,
Checkbox,
Confirm,
Header, Header,
Icon, Icon,
Item, Item,
@ -15,7 +17,7 @@ import {
} from "semantic-ui-react"; } from "semantic-ui-react";
import { useObservable } from "rxjs-hooks"; import { useObservable } from "rxjs-hooks";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { of, from, concat } from "rxjs"; import { of, from, concat, BehaviorSubject, combineLatest } from "rxjs";
import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; import { map, switchMap, distinctUntilChanged } from "rxjs/operators";
import _ from "lodash"; import _ from "lodash";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -23,7 +25,7 @@ import { useTranslation } from "react-i18next";
import type { ProcessingStatus, Track, UserDevice } from "types"; import type { ProcessingStatus, Track, UserDevice } from "types";
import { Page, FormattedDate, Visibility } from "components"; import { Page, FormattedDate, Visibility } from "components";
import api from "api"; import api from "api";
import { formatDistance, formatDuration } from "utils"; import { useCallbackRef, formatDistance, formatDuration } from "utils";
const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = { const COLOR_BY_STATUS: Record<ProcessingStatus, SemanticCOLORS> = {
error: "red", error: "red",
@ -150,11 +152,23 @@ function TrackFilters({
); );
} }
function TracksTable() { function TracksTable({ title }) {
const [orderBy, setOrderBy] = useState("recordedAt"); const [orderBy, setOrderBy] = useState("recordedAt");
const [reversed, setReversed] = useState(false); const [reversed, setReversed] = useState(false);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<Filters>({}); const [filters, setFilters] = useState<Filters>({});
const [selectedTracks, setSelectedTracks] = useState<Record<string, boolean>>(
{}
);
const toggleTrackSelection = useCallbackRef(
(slug: string, selected?: boolean) => {
const newSelected = selected ?? !selectedTracks[slug];
setSelectedTracks(
_.pickBy({ ...selectedTracks, [slug]: newSelected }, _.identity)
);
}
);
const query = _.pickBy( const query = _.pickBy(
{ {
@ -168,12 +182,17 @@ function TracksTable() {
(x) => x != null (x) => x != null
); );
const forceUpdate$ = useMemo(() => new BehaviorSubject(null), []);
const tracks: Track[] | null = useObservable( const tracks: Track[] | null = useObservable(
(_$, inputs$) => (_$, inputs$) =>
combineLatest([
inputs$.pipe( inputs$.pipe(
map(([query]) => query), map(([query]) => query),
distinctUntilChanged(_.isEqual), distinctUntilChanged(_.isEqual)
switchMap((query) => ),
forceUpdate$,
]).pipe(
switchMap(([query]) =>
concat( concat(
of(null), of(null),
from(api.get("/tracks/feed", { query }).then((r) => r.tracks)) from(api.get("/tracks/feed", { query }).then((r) => r.tracks))
@ -201,11 +220,62 @@ function TracksTable() {
const p = { orderBy, setOrderBy, reversed, setReversed }; const p = { orderBy, setOrderBy, reversed, setReversed };
const selectedCount = Object.keys(selectedTracks).length;
const noneSelected = selectedCount === 0;
const allSelected = selectedCount === tracks?.length;
const selectAll = () => {
setSelectedTracks(
Object.fromEntries(tracks?.map((t) => [t.slug, true]) ?? [])
);
};
const selectNone = () => {
setSelectedTracks({});
};
const bulkAction = async (action: string) => {
await api.post("/tracks/bulk", {
body: {
action,
tracks: Object.keys(selectedTracks),
},
});
setShowBulkDelete(false);
setSelectedTracks({});
forceUpdate$.next(null);
};
const [showBulkDelete, setShowBulkDelete] = useState(false);
return ( return (
<>
<div style={{ float: "right" }}>
<Dropdown disabled={noneSelected} text="Bulk actions" floating button>
<Dropdown.Menu>
<Dropdown.Header>
Selection of {selectedCount} tracks
</Dropdown.Header>
<Dropdown.Item onClick={() => bulkAction("makePrivate")}>
Make private
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("makePublic")}>
Make public
</Dropdown.Item>
<Dropdown.Item onClick={() => bulkAction("reprocess")}>
Reprocess
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowBulkDelete(true)}>
Delete
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Link component={UploadButton} to="/upload" />
</div>
<Header as="h2">{title}</Header>
<div style={{ clear: "both" }}> <div style={{ clear: "both" }}>
<Loader content={t("general.loading")} active={tracks == null} /> <Loader content={t("general.loading")} active={tracks == null} />
<Accordion styled> <Accordion>
<Accordion.Title <Accordion.Title
active={showFilters} active={showFilters}
index={0} index={0}
@ -219,9 +289,26 @@ function TracksTable() {
</Accordion.Content> </Accordion.Content>
</Accordion> </Accordion>
<Confirm
open={showBulkDelete}
onCancel={() => setShowBulkDelete(false)}
onConfirm={() => bulkAction("delete")}
content={`Are you sure you want to delete ${selectedCount} tracks?`}
confirmButton={t("general.delete")}
cancelButton={t("general.cancel")}
/>
<Table compact> <Table compact>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.HeaderCell>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && !noneSelected}
onClick={() => (noneSelected ? selectAll() : selectNone())}
/>
</Table.HeaderCell>
<SortableHeader {...p} name="title"> <SortableHeader {...p} name="title">
Title Title
</SortableHeader> </SortableHeader>
@ -246,6 +333,12 @@ function TracksTable() {
<Table.Body> <Table.Body>
{tracks?.map((track: Track) => ( {tracks?.map((track: Track) => (
<Table.Row key={track.slug}> <Table.Row key={track.slug}>
<Table.Cell>
<Checkbox
onClick={(e) => toggleTrackSelection(track.slug)}
checked={selectedTracks[track.slug] ?? false}
/>
</Table.Cell>
<Table.Cell> <Table.Cell>
{track.processingStatus == null ? null : ( {track.processingStatus == null ? null : (
<ProcessingStatusLabel status={track.processingStatus} /> <ProcessingStatusLabel status={track.processingStatus} />
@ -283,6 +376,7 @@ function TracksTable() {
</Table.Body> </Table.Body>
</Table> </Table>
</div> </div>
</>
); );
} }
@ -296,12 +390,7 @@ function UploadButton({ navigate, ...props }) {
[navigate] [navigate]
); );
return ( return (
<Button <Button onClick={onClick} {...props} color="green">
onClick={onClick}
{...props}
color="green"
style={{ float: "right" }}
>
{t("TracksPage.upload")} {t("TracksPage.upload")}
</Button> </Button>
); );
@ -315,9 +404,7 @@ const MyTracksPage = connect((state) => ({ login: (state as any).login }))(
return ( return (
<Page title={title}> <Page title={title}>
<Link component={UploadButton} to="/upload" /> <TracksTable {...{ title }} />
<Header as="h2">{title}</Header>
<TracksTable />
</Page> </Page>
); );
} }