mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Consolidating deletion endpoints.
This commit is contained in:
parent
09a967535b
commit
ba63170e55
24 changed files with 309 additions and 320 deletions
|
|
@ -247,19 +247,19 @@ export async function getVideoCaptions(fileGroupId) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function deleteVideos(fileGroupIds, force = false) {
|
||||
export async function deleteFileGroups(fileGroupIds, force = false) {
|
||||
if (!fileGroupIds || fileGroupIds.length === 0) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Empty request',
|
||||
description: 'Unable to delete Videos because no IDs were passed.',
|
||||
description: 'Unable to delete FileGroups because no IDs were passed.',
|
||||
time: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.info(`Deleting Videos: ${fileGroupIds}`);
|
||||
const i = fileGroupIds.join(',');
|
||||
const url = force ? `${VIDEOS_API}/${i}?force=true` : `${VIDEOS_API}/${i}`;
|
||||
const response = await apiDelete(url);
|
||||
console.info(`Deleting FileGroups: ${fileGroupIds}`);
|
||||
const body = {file_group_ids: fileGroupIds, force};
|
||||
const response = await apiPost(`${API_URI}/files/delete_groups`, body);
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
|
|
@ -271,10 +271,10 @@ export async function deleteVideos(fileGroupIds, force = false) {
|
|||
} catch (e) {
|
||||
// Not JSON, fall through to generic error.
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Failed to delete videos.');
|
||||
const message = await getErrorMessage(response, 'Failed to delete files.');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Deleting Videos failed',
|
||||
title: 'Delete failed',
|
||||
description: message,
|
||||
time: 5000,
|
||||
});
|
||||
|
|
@ -751,36 +751,6 @@ export async function deleteItems(itemIds) {
|
|||
return response
|
||||
}
|
||||
|
||||
export async function deleteArchives(archiveIds, force = false) {
|
||||
if (!archiveIds || archiveIds.length === 0) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Empty request',
|
||||
description: 'Unable to delete Archives because no IDs were passed.',
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
console.log(`Deleting Archives: ${archiveIds}`);
|
||||
let i = archiveIds.join(',');
|
||||
const url = force ? `${ARCHIVES_API}/${i}?force=true` : `${ARCHIVES_API}/${i}`;
|
||||
const response = await apiDelete(url);
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
try {
|
||||
const json = await response.clone().json();
|
||||
if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) {
|
||||
return {tagged: true, file_groups: json.file_groups};
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, fall through to generic error.
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Failed to delete archives');
|
||||
toast({
|
||||
type: 'error', title: 'Unexpected server response', description: message, time: 5000,
|
||||
});
|
||||
throw Error('Failed to delete archives');
|
||||
}
|
||||
|
||||
export async function searchArchives(offset, limit, domain, searchStr, order, tagNames, headline) {
|
||||
// Build a search query to retrieve a list of videos from the API
|
||||
|
|
@ -2253,30 +2223,6 @@ export async function getDoc(fileGroupId) {
|
|||
return await apiGet(`${DOCS_API}/${fileGroupId}`);
|
||||
}
|
||||
|
||||
export async function deleteDocs(docIds, force = false) {
|
||||
const ids = Array.isArray(docIds) ? docIds.join(',') : docIds;
|
||||
const url = force ? `${DOCS_API}/${ids}?force=true` : `${DOCS_API}/${ids}`;
|
||||
const response = await apiDelete(url);
|
||||
if (response.ok) {
|
||||
return response;
|
||||
}
|
||||
try {
|
||||
const json = await response.clone().json();
|
||||
if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) {
|
||||
return {tagged: true, file_groups: json.file_groups};
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, fall through to generic error.
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Failed to delete documents.');
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Deleting Documents failed',
|
||||
description: message,
|
||||
time: 5000,
|
||||
});
|
||||
throw Error(message);
|
||||
}
|
||||
|
||||
export async function searchDocs(offset, limit, searchStr, order, tagNames, author, subject, mimetype) {
|
||||
offset = parseInt(offset || 0);
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ import {
|
|||
useTitle
|
||||
} from "./Common";
|
||||
import {
|
||||
deleteArchives,
|
||||
deleteDomain,
|
||||
deleteFileGroups,
|
||||
fetchArchiveBrowsers,
|
||||
fetchArchiveDownloaderConfig,
|
||||
generateArchiveScreenshot,
|
||||
|
|
@ -135,7 +135,7 @@ function ArchivePage() {
|
|||
null;
|
||||
|
||||
const localDeleteArchive = async (force = false) => {
|
||||
const result = await deleteArchives([archiveFile.id], force);
|
||||
const result = await deleteFileGroups([archiveFile.id], force);
|
||||
if (result && result.tagged) {
|
||||
setTaggedFileGroups(result.file_groups);
|
||||
return;
|
||||
|
|
@ -1149,13 +1149,13 @@ function ArchivesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const archiveIdsForSelection = () => archives
|
||||
const fileGroupIdsForSelection = () => archives
|
||||
.filter(i => selectedArchives.indexOf(i['primary_path']) >= 0)
|
||||
.map(i => i['id']);
|
||||
|
||||
const onDelete = async (force = false) => {
|
||||
const archiveIds = archiveIdsForSelection();
|
||||
const result = await deleteArchives(archiveIds, force);
|
||||
const fileGroupIds = fileGroupIdsForSelection();
|
||||
const result = await deleteFileGroups(fileGroupIds, force);
|
||||
if (result && result.tagged) {
|
||||
setTaggedFileGroups(result.file_groups);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {Link, Route, Routes, useLocation, useNavigate, useParams, useSearchParams} from "react-router";
|
||||
import {Grid, Icon as SIcon, Loader, StatisticLabel, StatisticValue} from "semantic-ui-react";
|
||||
import {deleteDocs, getDocStatistics, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {deleteFileGroups, getDocStatistics, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {Media, ThemeContext} from "../contexts/contexts";
|
||||
import {
|
||||
APIButton,
|
||||
|
|
@ -59,8 +59,8 @@ function DocsPage() {
|
|||
}
|
||||
|
||||
const onDelete = async (force = false) => {
|
||||
const docIds = docs.filter(i => selectedDocs.indexOf(i['primary_path']) >= 0).map(i => i['id']);
|
||||
const result = await deleteDocs(docIds, force);
|
||||
const fileGroupIds = docs.filter(i => selectedDocs.indexOf(i['primary_path']) >= 0).map(i => i['id']);
|
||||
const result = await deleteFileGroups(fileGroupIds, force);
|
||||
if (result && result.tagged) {
|
||||
setTaggedFileGroups(result.file_groups);
|
||||
return;
|
||||
|
|
@ -262,7 +262,7 @@ function DocPage() {
|
|||
|
||||
const handleDelete = async (force = false) => {
|
||||
if (doc && doc.id) {
|
||||
const result = await deleteDocs([docFile.id], force);
|
||||
const result = await deleteFileGroups([docFile.id], force);
|
||||
if (result && result.tagged) {
|
||||
setTaggedFileGroups(result.file_groups);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {useHotkeys} from 'react-hotkeys-hook';
|
||||
import {deleteVideos, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {deleteFileGroups, downloadVideoMetadata, tagFileGroup, untagFileGroup} from "../api";
|
||||
import {Link, useNavigate, useParams} from "react-router";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
|
|
@ -278,8 +278,8 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
|
|||
return <PageContainer><Header as='h4'>Could not find video</Header></PageContainer>;
|
||||
}
|
||||
|
||||
const handleDeleteVideo = async (video_id, force = false) => {
|
||||
const result = await deleteVideos([video_id], force);
|
||||
const handleDeleteVideo = async (fileGroupId, force = false) => {
|
||||
const result = await deleteFileGroups([fileGroupId], force);
|
||||
if (result && result.tagged) {
|
||||
setTaggedFileGroups(result.file_groups);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import {Button, Card, Header, Loader, Modal, Placeholder, Popup, Segment, Statis
|
|||
import {BulkTagModal} from "./BulkTagModal";
|
||||
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
|
||||
import {
|
||||
deleteVideos,
|
||||
deleteFileGroups,
|
||||
deleteCookies,
|
||||
fetchSuggestedUserAgent,
|
||||
fetchVideoDownloaderConfig,
|
||||
|
|
@ -160,7 +160,7 @@ export function VideosPage() {
|
|||
|
||||
const onDelete = async (force = false) => {
|
||||
const fileGroupIds = videos.filter(i => selectedVideos.indexOf(i['primary_path']) >= 0).map(i => i['id']);
|
||||
const result = await deleteVideos(fileGroupIds, force);
|
||||
const result = await deleteFileGroups(fileGroupIds, force);
|
||||
if (result && result.tagged) {
|
||||
setTaggedFileGroups(result.file_groups);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React, {useEffect, useState} from 'react';
|
|||
import {Card, Grid, Icon, Image, Label, Segment, Button as SButton} from 'semantic-ui-react';
|
||||
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
|
||||
import {Button, Modal} from '../Theme';
|
||||
import {deleteArchives, deleteVideos} from '../../api';
|
||||
import {deleteFileGroups} from '../../api';
|
||||
import {humanFileSize, isoDatetimeToString} from '../Common';
|
||||
import {TaggedDeleteConfirmModal} from '../TaggedDeleteConfirmModal';
|
||||
|
||||
|
|
@ -32,10 +32,8 @@ function ConflictFileCard({file, onDelete, isRecommended}) {
|
|||
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
|
||||
|
||||
const performDelete = async (force) => {
|
||||
if (file.model_type === 'video' && file.file_group_id) {
|
||||
return await deleteVideos([file.file_group_id], force);
|
||||
} else if (file.model_type === 'archive' && file.archive_id) {
|
||||
return await deleteArchives([file.archive_id], force);
|
||||
if (file.file_group_id) {
|
||||
return await deleteFileGroups([file.file_group_id], force);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import * as api from '../../api';
|
|||
|
||||
// Mock the API functions
|
||||
jest.mock('../../api', () => ({
|
||||
deleteVideos: jest.fn(),
|
||||
deleteArchives: jest.fn(),
|
||||
deleteFileGroups: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-semantic-toasts-2
|
||||
|
|
@ -97,8 +96,8 @@ describe('ConflictResolutionModal', () => {
|
|||
expect(screen.getByText('2.0 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls deleteVideos when delete button is clicked for video', async () => {
|
||||
api.deleteVideos.mockResolvedValue({});
|
||||
it('calls deleteFileGroups when delete button is clicked for video', async () => {
|
||||
api.deleteFileGroups.mockResolvedValue({});
|
||||
|
||||
renderWithProviders(<ConflictResolutionModal {...defaultProps} />);
|
||||
|
||||
|
|
@ -107,7 +106,7 @@ describe('ConflictResolutionModal', () => {
|
|||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteVideos).toHaveBeenCalledWith([1], false);
|
||||
expect(api.deleteFileGroups).toHaveBeenCalledWith([1], false);
|
||||
});
|
||||
|
||||
// File should be removed from UI (conflict resolved since only 1 file remains)
|
||||
|
|
@ -123,7 +122,7 @@ describe('ConflictResolutionModal', () => {
|
|||
expect(defaultProps.onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls deleteArchives when delete button is clicked for archive', async () => {
|
||||
it('calls deleteFileGroups when delete button is clicked for archive', async () => {
|
||||
const archiveConflicts = [
|
||||
{
|
||||
destination_path: 'archives/example.com/page.html',
|
||||
|
|
@ -143,7 +142,7 @@ describe('ConflictResolutionModal', () => {
|
|||
},
|
||||
];
|
||||
|
||||
api.deleteArchives.mockResolvedValue({});
|
||||
api.deleteFileGroups.mockResolvedValue({});
|
||||
|
||||
renderWithProviders(
|
||||
<ConflictResolutionModal
|
||||
|
|
@ -157,7 +156,8 @@ describe('ConflictResolutionModal', () => {
|
|||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteArchives).toHaveBeenCalledWith([201], false);
|
||||
// Unified endpoint always uses FileGroup IDs.
|
||||
expect(api.deleteFileGroups).toHaveBeenCalledWith([10], false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -319,7 +319,7 @@ describe('ConflictResolutionModal', () => {
|
|||
});
|
||||
|
||||
it('shows tagged-delete confirmation modal when API returns FILE_GROUP_IS_TAGGED', async () => {
|
||||
api.deleteVideos.mockResolvedValue({
|
||||
api.deleteFileGroups.mockResolvedValue({
|
||||
tagged: true,
|
||||
file_groups: [
|
||||
{id: 1, primary_path: 'unique-tagged-path-12345.mp4', tags: ['unique-tag-zxcv']},
|
||||
|
|
@ -338,8 +338,8 @@ describe('ConflictResolutionModal', () => {
|
|||
});
|
||||
expect(screen.getByText('unique-tag-zxcv')).toBeInTheDocument();
|
||||
|
||||
// Confirming should re-call deleteVideos with force=true.
|
||||
api.deleteVideos.mockResolvedValueOnce({});
|
||||
// Confirming should re-call deleteFileGroups with force=true.
|
||||
api.deleteFileGroups.mockResolvedValueOnce({});
|
||||
// The TaggedDeleteConfirmModal's Delete button has the trash icon.
|
||||
const confirmButton = screen.getAllByRole('button')
|
||||
.find(b => b.classList.contains('red') && b.textContent.includes('Delete')
|
||||
|
|
@ -347,7 +347,7 @@ describe('ConflictResolutionModal', () => {
|
|||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteVideos).toHaveBeenLastCalledWith([1], true);
|
||||
expect(api.deleteFileGroups).toHaveBeenLastCalledWith([1], true);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from wrolpi.api_utils import json_response, api_app
|
|||
from wrolpi.cmd import get_installed_browsers
|
||||
from wrolpi.common import logger, wrol_mode_check, api_param_limiter, TRACE_LEVEL
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.errors import ValidationError
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.schema import JSONErrorResponse
|
||||
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
||||
|
|
@ -44,26 +43,6 @@ async def get_archive(request: Request, file_group_id: int):
|
|||
return json_response({'file_group': archive_file_group, 'history': history})
|
||||
|
||||
|
||||
@archive_bp.delete('/<file_group_ids:int>', name='archive_delete_one')
|
||||
@archive_bp.delete('/<file_group_ids:[0-9,]+>', name='archive_delete_many')
|
||||
@openapi.description('Delete Archives by FileGroup IDs. Pass ?force=true to delete even tagged FileGroups.')
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
@wrol_mode_check
|
||||
async def delete_archive(request: Request, file_group_ids: str):
|
||||
try:
|
||||
file_group_ids = [int(i) for i in str(file_group_ids).split(',')]
|
||||
except ValueError:
|
||||
raise ValidationError('Could not parse file group ids')
|
||||
force = request.args.get('force', '').lower() == 'true'
|
||||
tagged = lib.delete_archives_by_file_group_ids(*file_group_ids, force=force)
|
||||
if tagged:
|
||||
return json_response(
|
||||
{'code': 'FILE_GROUP_IS_TAGGED', 'file_groups': tagged},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
return response.empty()
|
||||
|
||||
|
||||
archive_limit_limiter = api_param_limiter(100)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ from wrolpi.dates import now, Seconds
|
|||
from wrolpi.db import get_db_session, get_db_curs
|
||||
from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.files.lib import handle_file_group_search_results, get_tagged_file_groups_by_ids
|
||||
from wrolpi.files.lib import handle_file_group_search_results
|
||||
from wrolpi.files.models import FileGroup
|
||||
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
|
||||
from wrolpi.tags import tag_append_sub_select_where, save_tags_config
|
||||
from wrolpi.tags import tag_append_sub_select_where
|
||||
from wrolpi.vars import PYTEST, DOCKERIZED
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
|
@ -1125,29 +1125,16 @@ def get_archive_by_file_group_id(session: Session, file_group_id: int, skip_view
|
|||
return archive
|
||||
|
||||
|
||||
def delete_archives_by_file_group_ids(*file_group_ids: int, force: bool = False) -> Optional[List[dict]]:
|
||||
"""Delete Archives by their FileGroup IDs and all of their files.
|
||||
|
||||
If any FileGroup is tagged and force is False, returns the list of serialized tagged FileGroups
|
||||
without deleting anything. If force is True, deletes even tagged files and re-saves the tags
|
||||
config so `tags.yaml` reflects the removed TagFiles.
|
||||
"""
|
||||
def delete_archives_by_file_group_ids(*file_group_ids: int, force: bool = False) -> None:
|
||||
"""Delete Archives by their FileGroup IDs and all of their files. Pass force=True to delete
|
||||
even tagged FileGroups (raises FileGroupIsTagged otherwise via FileGroup.delete)."""
|
||||
with get_db_session(commit=True) as session:
|
||||
archives: List[Archive] = list(session.query(Archive).filter(Archive.file_group_id.in_(file_group_ids)))
|
||||
if not archives:
|
||||
raise UnknownArchive(f'Unknown Archives with FileGroup IDs: {", ".join(map(str, file_group_ids))}')
|
||||
|
||||
tagged = get_tagged_file_groups_by_ids(session, [a.file_group_id for a in archives])
|
||||
if tagged and not force:
|
||||
return tagged
|
||||
|
||||
for archive in archives:
|
||||
archive.delete(force=force)
|
||||
|
||||
if tagged:
|
||||
save_tags_config.activate_switch()
|
||||
return None
|
||||
|
||||
|
||||
def archive_strftime(dt: datetime) -> str:
|
||||
return dt.strftime('%Y-%m-%d-%H-%M-%S')
|
||||
|
|
|
|||
|
|
@ -514,13 +514,14 @@ async def test_archive_browsers_endpoint_dockerized(async_client):
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_archive_untagged(async_client, test_session, archive_factory):
|
||||
"""An untagged Archive can be deleted via DELETE /api/archive/<id> without force."""
|
||||
"""An untagged Archive can be deleted via the unified delete endpoint without force."""
|
||||
archive = archive_factory('example.com', 'https://example.com/1')
|
||||
test_session.commit()
|
||||
paths = list(archive.my_paths())
|
||||
assert paths and all(p.is_file() for p in paths)
|
||||
|
||||
request, response = await async_client.delete(f'/api/archive/{archive.file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [archive.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert all(not p.exists() for p in paths)
|
||||
assert test_session.query(Archive).count() == 0
|
||||
|
|
@ -535,7 +536,8 @@ async def test_delete_archive_tagged_requires_force(async_client, test_session,
|
|||
test_session.commit()
|
||||
paths = list(archive.my_paths())
|
||||
|
||||
request, response = await async_client.delete(f'/api/archive/{archive.file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [archive.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'FILE_GROUP_IS_TAGGED'
|
||||
assert len(response.json['file_groups']) == 1
|
||||
|
|
@ -547,7 +549,8 @@ async def test_delete_archive_tagged_requires_force(async_client, test_session,
|
|||
assert test_session.query(Archive).count() == 1
|
||||
|
||||
# With force=true, the Archive is deleted.
|
||||
request, response = await async_client.delete(f'/api/archive/{archive.file_group_id}?force=true')
|
||||
body = json.dumps({'file_group_ids': [archive.file_group_id], 'force': True})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert all(not p.exists() for p in paths)
|
||||
assert test_session.query(Archive).count() == 0
|
||||
|
|
@ -564,8 +567,8 @@ async def test_delete_archive_atomic_when_some_tagged(async_client, test_session
|
|||
untagged_paths = list(untagged.my_paths())
|
||||
tagged_paths = list(tagged.my_paths())
|
||||
|
||||
ids = f'{untagged.file_group_id},{tagged.file_group_id}'
|
||||
request, response = await async_client.delete(f'/api/archive/{ids}')
|
||||
body = json.dumps({'file_group_ids': [untagged.file_group_id, tagged.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'FILE_GROUP_IS_TAGGED'
|
||||
# Only the tagged FileGroup is in the response.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from sanic import response, Request, Blueprint
|
||||
from sanic import Request, Blueprint
|
||||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from wrolpi.api_utils import json_response
|
||||
from wrolpi.common import logger, wrol_mode_check, api_param_limiter
|
||||
from wrolpi.errors import ValidationError
|
||||
from wrolpi.common import logger, api_param_limiter
|
||||
from wrolpi.schema import JSONErrorResponse
|
||||
from . import schema
|
||||
from .lib import get_statistics, _doc_response, _get_doc, _delete_docs, _search_docs
|
||||
from .lib import get_statistics, _doc_response, _get_doc, _search_docs
|
||||
|
||||
NAME = 'docs'
|
||||
|
||||
|
|
@ -34,26 +33,6 @@ async def get_doc(request: Request, file_group_id: int):
|
|||
return json_response(_doc_response(doc))
|
||||
|
||||
|
||||
@docs_bp.delete('/<file_group_ids:int>', name='doc_delete_one')
|
||||
@docs_bp.delete('/<file_group_ids:[0-9,]+>', name='doc_delete_many')
|
||||
@openapi.description('Delete Docs by FileGroup IDs. Pass ?force=true to delete even tagged FileGroups.')
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
@wrol_mode_check
|
||||
async def delete_docs(request: Request, file_group_ids: str):
|
||||
try:
|
||||
file_group_ids = [int(i) for i in str(file_group_ids).split(',')]
|
||||
except ValueError:
|
||||
raise ValidationError('Could not parse file group ids')
|
||||
force = request.args.get('force', '').lower() == 'true'
|
||||
tagged = _delete_docs(*file_group_ids, force=force)
|
||||
if tagged:
|
||||
return json_response(
|
||||
{'code': 'FILE_GROUP_IS_TAGGED', 'file_groups': tagged},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
return response.empty()
|
||||
|
||||
|
||||
doc_limit_limiter = api_param_limiter(100)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ from wrolpi.collections.models import Collection
|
|||
from wrolpi.collections.types import collection_type_registry
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.errors import ValidationError
|
||||
from wrolpi.files.lib import split_path_stem_and_suffix, get_mimetype, get_tagged_file_groups_by_ids
|
||||
from wrolpi.tags import save_tags_config
|
||||
from wrolpi.files.lib import split_path_stem_and_suffix, get_mimetype
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -347,35 +346,6 @@ def _get_doc(session, file_group_id: int):
|
|||
return doc
|
||||
|
||||
|
||||
def _delete_docs(*file_group_ids, force: bool = False) -> Optional[List[dict]]:
|
||||
"""Delete Docs by their FileGroup IDs and all of their files.
|
||||
|
||||
If any FileGroup is tagged and force is False, returns the list of serialized tagged FileGroups
|
||||
without deleting anything. If force is True, deletes even tagged files and re-saves the tags
|
||||
config so `tags.yaml` reflects the removed TagFiles.
|
||||
"""
|
||||
from .models import Doc
|
||||
with get_db_session(commit=True) as session:
|
||||
docs = list(session.query(Doc).filter(Doc.file_group_id.in_(file_group_ids)))
|
||||
if not docs:
|
||||
raise ValidationError(
|
||||
f'Could not find Docs to delete with file_group_ids: {", ".join(map(str, file_group_ids))}'
|
||||
)
|
||||
|
||||
tagged = get_tagged_file_groups_by_ids(session, [d.file_group_id for d in docs])
|
||||
if tagged and not force:
|
||||
return tagged
|
||||
|
||||
# Delete the FileGroup, which removes the files on disk and cascades the Doc row via
|
||||
# the ON DELETE CASCADE foreign key on Doc.file_group_id.
|
||||
for doc in docs:
|
||||
doc.file_group.delete(force=force)
|
||||
|
||||
if tagged:
|
||||
save_tags_config.activate_switch()
|
||||
return None
|
||||
|
||||
|
||||
def _search_docs(search_str=None, author=None, subject=None, language=None, mimetype=None,
|
||||
limit=20, offset=0, order_by='published_datetime', tag_names=None):
|
||||
from .models import Doc
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ class Doc(ModelHelper, Base):
|
|||
d['data']['ebook_path'] = self.file_group.resolve_path(d['data']['ebook_path'])
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def get_by_path(session: Session, path) -> Optional['Doc']:
|
||||
return session.query(Doc).join(FileGroup).filter(FileGroup.primary_path == str(path)).one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def can_model(file_group: FileGroup) -> bool:
|
||||
if file_group.mimetype and mimetype_is_doc(file_group.mimetype):
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ async def test_delete_doc(async_client, test_session, test_directory, example_ep
|
|||
paths = list(doc.file_group.my_paths())
|
||||
assert paths and all(p.is_file() for p in paths)
|
||||
|
||||
request, response = await async_client.delete(f'/api/docs/{file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Doc, FileGroup, and on-disk files all removed.
|
||||
|
|
@ -99,7 +100,7 @@ async def test_delete_doc(async_client, test_session, test_directory, example_ep
|
|||
@pytest.mark.asyncio
|
||||
async def test_delete_doc_tagged_requires_force(async_client, test_session, doc_factory, tag_factory,
|
||||
await_switches):
|
||||
"""DELETE /api/docs/<id> returns 409 for tagged docs until ?force=true is passed."""
|
||||
"""The unified delete endpoint returns 409 for tagged docs until force=true is passed."""
|
||||
from wrolpi.files.models import FileGroup
|
||||
tag = await tag_factory()
|
||||
doc = doc_factory()
|
||||
|
|
@ -108,7 +109,8 @@ async def test_delete_doc_tagged_requires_force(async_client, test_session, doc_
|
|||
file_group_id = doc.file_group_id
|
||||
paths = list(doc.file_group.my_paths())
|
||||
|
||||
request, response = await async_client.delete(f'/api/docs/{file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'FILE_GROUP_IS_TAGGED'
|
||||
assert len(response.json['file_groups']) == 1
|
||||
|
|
@ -119,7 +121,8 @@ async def test_delete_doc_tagged_requires_force(async_client, test_session, doc_
|
|||
assert test_session.query(Doc).count() == 1
|
||||
|
||||
# With force=true, the Doc is deleted.
|
||||
request, response = await async_client.delete(f'/api/docs/{file_group_id}?force=true')
|
||||
body = json.dumps({'file_group_ids': [file_group_id], 'force': True})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert all(not p.exists() for p in paths)
|
||||
assert test_session.query(Doc).count() == 0
|
||||
|
|
@ -129,7 +132,7 @@ async def test_delete_doc_tagged_requires_force(async_client, test_session, doc_
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_doc_atomic_when_some_tagged(async_client, test_session, doc_factory, tag_factory):
|
||||
"""When deleting multiple Docs and any are tagged, NONE are deleted until ?force=true."""
|
||||
"""When deleting multiple Docs and any are tagged, NONE are deleted until force=true."""
|
||||
untagged = doc_factory()
|
||||
tagged = doc_factory()
|
||||
tag = await tag_factory()
|
||||
|
|
@ -138,8 +141,8 @@ async def test_delete_doc_atomic_when_some_tagged(async_client, test_session, do
|
|||
untagged_paths = list(untagged.file_group.my_paths())
|
||||
tagged_paths = list(tagged.file_group.my_paths())
|
||||
|
||||
ids = f'{untagged.file_group_id},{tagged.file_group_id}'
|
||||
request, response = await async_client.delete(f'/api/docs/{ids}')
|
||||
body = json.dumps({'file_group_ids': [untagged.file_group_id, tagged.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'FILE_GROUP_IS_TAGGED'
|
||||
returned_ids = {fg['id'] for fg in response.json['file_groups']}
|
||||
|
|
|
|||
|
|
@ -307,7 +307,8 @@ async def test_delete_video(async_client, test_session, video_factory, test_down
|
|||
files = video.file_group.my_paths()
|
||||
test_session.commit()
|
||||
|
||||
request, response = await async_client.delete(f'/api/videos/{video.file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [video.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
assert test_session.query(Video).count() == 0, 'Video was not deleted.'
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from datetime import datetime, timezone
|
||||
from http import HTTPStatus
|
||||
|
||||
|
|
@ -49,7 +50,7 @@ async def test_delete_video_with_tag(async_client, test_session, video_factory,
|
|||
@pytest.mark.asyncio
|
||||
async def test_delete_video_api_tagged_requires_force(async_client, test_session, video_factory, tag_factory,
|
||||
await_switches):
|
||||
"""DELETE /api/videos/<id> returns 409 for tagged videos until ?force=true is passed."""
|
||||
"""The unified delete endpoint returns 409 for tagged videos until force=true is passed."""
|
||||
video = video_factory(with_video_file=True, with_poster_ext='png')
|
||||
tag = await tag_factory()
|
||||
video.add_tag(test_session, tag.name)
|
||||
|
|
@ -57,7 +58,8 @@ async def test_delete_video_api_tagged_requires_force(async_client, test_session
|
|||
file_group_id = video.file_group_id
|
||||
paths = list(video.file_group.my_paths())
|
||||
|
||||
request, response = await async_client.delete(f'/api/videos/{file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'FILE_GROUP_IS_TAGGED'
|
||||
assert len(response.json['file_groups']) == 1
|
||||
|
|
@ -68,7 +70,8 @@ async def test_delete_video_api_tagged_requires_force(async_client, test_session
|
|||
assert test_session.query(Video).count() == 1
|
||||
|
||||
# With force=true, the Video is deleted.
|
||||
request, response = await async_client.delete(f'/api/videos/{file_group_id}?force=true')
|
||||
body = json.dumps({'file_group_ids': [file_group_id], 'force': True})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
assert all(not p.exists() for p in paths)
|
||||
assert test_session.query(Video).count() == 0
|
||||
|
|
@ -78,7 +81,7 @@ async def test_delete_video_api_tagged_requires_force(async_client, test_session
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_video_api_atomic_when_some_tagged(async_client, test_session, video_factory, tag_factory):
|
||||
"""When deleting multiple Videos and any are tagged, NONE are deleted until ?force=true."""
|
||||
"""When deleting multiple Videos and any are tagged, NONE are deleted until force=true."""
|
||||
untagged = video_factory(with_video_file=True)
|
||||
tagged = video_factory(with_video_file=True)
|
||||
tag = await tag_factory()
|
||||
|
|
@ -87,8 +90,8 @@ async def test_delete_video_api_atomic_when_some_tagged(async_client, test_sessi
|
|||
untagged_paths = list(untagged.file_group.my_paths())
|
||||
tagged_paths = list(tagged.file_group.my_paths())
|
||||
|
||||
ids = f'{untagged.file_group_id},{tagged.file_group_id}'
|
||||
request, response = await async_client.delete(f'/api/videos/{ids}')
|
||||
body = json.dumps({'file_group_ids': [untagged.file_group_id, tagged.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'FILE_GROUP_IS_TAGGED'
|
||||
returned_ids = {fg['id'] for fg in response.json['file_groups']}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from sanic import response, Blueprint
|
||||
from sanic import Blueprint
|
||||
from sanic.request import Request
|
||||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from wrolpi.api_utils import json_response
|
||||
from wrolpi.common import logger, wrol_mode_check
|
||||
from wrolpi.errors import InvalidOrderBy, ValidationError
|
||||
from wrolpi.events import Events
|
||||
from wrolpi.common import logger
|
||||
from wrolpi.errors import InvalidOrderBy
|
||||
from wrolpi.schema import JSONErrorResponse
|
||||
from . import lib
|
||||
from .. import schema
|
||||
from ..lib import save_channels_config
|
||||
|
||||
video_bp = Blueprint('Video', '/api/videos')
|
||||
|
||||
|
|
@ -73,27 +71,3 @@ async def search_videos(_: Request, body: schema.VideoSearchRequest):
|
|||
return json_response(ret)
|
||||
|
||||
|
||||
@video_bp.delete('/<file_group_ids:[0-9,]+>', name='Video Delete Many')
|
||||
@video_bp.delete('/<file_group_ids:int>', name='Video Delete One')
|
||||
@openapi.description('Delete videos. Pass ?force=true to delete even tagged FileGroups.')
|
||||
@openapi.response(HTTPStatus.NO_CONTENT)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
@wrol_mode_check
|
||||
async def video_delete(request: Request, file_group_ids: str):
|
||||
try:
|
||||
file_group_ids = [int(i) for i in str(file_group_ids).split(',')]
|
||||
except Exception:
|
||||
raise ValidationError('Unable to parse file group ids')
|
||||
|
||||
session = request.ctx.session
|
||||
force = request.args.get('force', '').lower() == 'true'
|
||||
tagged = lib.delete_videos(session, *file_group_ids, force=force)
|
||||
if tagged:
|
||||
return json_response(
|
||||
{'code': 'FILE_GROUP_IS_TAGGED', 'file_groups': tagged},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
|
||||
save_channels_config.activate_switch()
|
||||
Events.send_deleted(f'Deleted {len(file_group_ids)} videos')
|
||||
return response.empty()
|
||||
|
|
|
|||
|
|
@ -12,12 +12,11 @@ from wrolpi.common import logger, limit_concurrent, wrol_mode_check
|
|||
from wrolpi.dates import now
|
||||
from wrolpi.db import get_db_session
|
||||
from wrolpi.downloader import download_manager
|
||||
from wrolpi.files.lib import handle_file_group_search_results, get_tagged_file_groups_by_ids
|
||||
from wrolpi.files.lib import handle_file_group_search_results
|
||||
from wrolpi.files.models import FileGroup
|
||||
from wrolpi.tags import tag_append_sub_select_where, save_tags_config
|
||||
from wrolpi.tags import tag_append_sub_select_where
|
||||
from wrolpi.vars import VIDEO_COMMENTS_FETCH_COUNT, YTDLP_CACHE_DIR
|
||||
from ..cookies import cookies_unlocked, cookies_for_download
|
||||
from ..errors import UnknownVideo
|
||||
from ..lib import get_yt_dlp_http_headers, get_yt_dlp_sleep_opts
|
||||
|
||||
logger.getChild(__name__)
|
||||
|
|
@ -162,34 +161,6 @@ def search_videos(
|
|||
return results, total
|
||||
|
||||
|
||||
def delete_videos(session: Session, *file_group_ids: int, force: bool = False) -> Optional[List[dict]]:
|
||||
"""Delete Videos by their FileGroup IDs and all of their files.
|
||||
|
||||
If any FileGroup is tagged and force is False, returns the list of serialized tagged FileGroups
|
||||
without deleting anything. If force is True, deletes even tagged files and re-saves the tags
|
||||
config so `tags.yaml` reflects the removed TagFiles.
|
||||
"""
|
||||
videos = list(session.query(Video).filter(Video.file_group_id.in_(file_group_ids)))
|
||||
if not videos:
|
||||
raise UnknownVideo('Could not find videos to delete')
|
||||
|
||||
tagged = get_tagged_file_groups_by_ids(session, [v.file_group_id for v in videos])
|
||||
if tagged and not force:
|
||||
return tagged
|
||||
|
||||
logger.warning(f'Deleting {len(videos)} videos')
|
||||
# Get all URLs, skip them once (so config isn't saved multiple times).
|
||||
urls = list(filter(None, [i.file_group.url for i in videos]))
|
||||
download_manager.add_to_skip_list(*urls)
|
||||
for video in videos:
|
||||
video.delete(force=force)
|
||||
session.commit()
|
||||
|
||||
if tagged:
|
||||
save_tags_config.activate_switch()
|
||||
return None
|
||||
|
||||
|
||||
def download_video_info_json(url: str) -> dict:
|
||||
"""Download video info JSON, using encrypted cookies for authentication if available."""
|
||||
ydl_opts = dict(
|
||||
|
|
|
|||
|
|
@ -120,31 +120,36 @@ async def test_delete_video_api(async_client, test_session, channel_factory, vid
|
|||
|
||||
vid1_video_path, vid1_caption_path = vid1.video_path, vid1.caption_paths[0]
|
||||
vid2_video_path, vid2_info_json_path = vid2.video_path, vid2.info_json_path
|
||||
# Capture URLs before deletion since cascade removes the Video rows from the session.
|
||||
vid1_url, vid2_url = vid1.file_group.url, vid2.file_group.url
|
||||
|
||||
# No videos have been deleted yet.
|
||||
assert vid1_video_path.is_file() and vid1_caption_path.is_file()
|
||||
assert vid2_video_path.is_file() and vid2_info_json_path.is_file()
|
||||
|
||||
request, response = await async_client.delete(f'/api/videos/{vid1.file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [vid1.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
# Video was added to skip list.
|
||||
assert test_download_manager.is_skipped(vid1.file_group.url)
|
||||
assert test_download_manager.is_skipped(vid1_url)
|
||||
# Video was deleted.
|
||||
assert test_session.query(Video).count() == 1
|
||||
assert vid1_video_path.is_file() is False and vid1_caption_path.is_file() is False
|
||||
assert vid2_video_path.is_file() and vid2_info_json_path.is_file()
|
||||
|
||||
request, response = await async_client.delete(f'/api/videos/{vid2.file_group_id}')
|
||||
body = json.dumps({'file_group_ids': [vid2.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
assert test_download_manager.is_skipped(vid1.file_group.url, vid2.file_group.url)
|
||||
assert test_download_manager.is_skipped(vid1_url, vid2_url)
|
||||
assert test_session.query(Video).count() == 0
|
||||
assert vid1_video_path.is_file() is False and vid1_caption_path.is_file() is False
|
||||
assert vid2_video_path.is_file() is False and vid2_info_json_path.is_file() is False
|
||||
|
||||
# 3 does not exist.
|
||||
request, response = await async_client.delete(f'/api/videos/3')
|
||||
body = json.dumps({'file_group_ids': [3]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
|
|
@ -169,7 +174,8 @@ async def test_wrol_mode(async_client, simple_channel, simple_video, wrol_mode_f
|
|||
assert resp.json['code'] == 'WROL_MODE_ENABLED'
|
||||
|
||||
# Can't delete a video
|
||||
_, resp = await async_client.delete(f'/api/videos/{simple_video.file_group_id}')
|
||||
body = dumps({'file_group_ids': [simple_video.file_group_id]})
|
||||
_, resp = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert resp.status_code == HTTPStatus.FORBIDDEN
|
||||
assert resp.json['code'] == 'WROL_MODE_ENABLED'
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from sanic_ext.extensions.openapi import openapi
|
|||
from wrolpi.common import get_media_directory, wrol_mode_check, get_relative_to_media_directory, logger, \
|
||||
background_task, walk, timer, TRACE_LEVEL, unique_by_predicate
|
||||
from wrolpi.errors import InvalidFile, UnknownDirectory, FileUploadFailed, FileConflict
|
||||
from wrolpi.events import Events
|
||||
from . import lib, schema
|
||||
from .worker import file_worker
|
||||
from ..api_utils import json_response, api_app
|
||||
|
|
@ -75,6 +76,38 @@ async def delete_file(_: Request, body: schema.DeleteRequest):
|
|||
return response.empty()
|
||||
|
||||
|
||||
@files_bp.post('/delete_groups')
|
||||
@openapi.definition(
|
||||
summary='Delete FileGroups (and their model records) by ID. Pass force=true to delete'
|
||||
' even tagged FileGroups.',
|
||||
body=schema.DeleteFileGroupsRequest,
|
||||
)
|
||||
@validate(schema.DeleteFileGroupsRequest)
|
||||
@wrol_mode_check
|
||||
async def delete_file_groups(request: Request, body: schema.DeleteFileGroupsRequest):
|
||||
if not body.file_group_ids:
|
||||
raise InvalidFile('file_group_ids cannot be empty')
|
||||
session = request.ctx.session
|
||||
|
||||
if not body.force:
|
||||
tagged = lib.get_tagged_file_groups_by_ids(session, body.file_group_ids)
|
||||
if tagged:
|
||||
return json_response(
|
||||
{'code': 'FILE_GROUP_IS_TAGGED', 'file_groups': tagged},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
|
||||
affected_models = lib.delete_file_groups(session, body.file_group_ids, force=body.force)
|
||||
|
||||
if 'video' in affected_models:
|
||||
# A Channel's tracked downloads may have changed; rewrite channels.yaml.
|
||||
from modules.videos.lib import save_channels_config
|
||||
save_channels_config.activate_switch()
|
||||
|
||||
Events.send_deleted(f'Deleted {len(body.file_group_ids)} files')
|
||||
return response.empty()
|
||||
|
||||
|
||||
@files_bp.post('/refresh')
|
||||
@openapi.definition(
|
||||
summary='Refresh and index all paths (files/directories) in the provided list. Refresh all files if not provided.',
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ __all__ = ['list_directories_contents', 'delete', 'split_path_stem_and_suffix',
|
|||
'get_mimetype', 'split_file_name_words', 'get_primary_file', 'get_file_statistics',
|
||||
'search_file_suggestion_count', 'glob_shared_stem', 'upsert_file', 'get_unique_files_by_stem',
|
||||
'rename', 'delete_directory', 'handle_file_group_search_results', 'get_file_location_href',
|
||||
'get_tagged_file_groups_by_ids']
|
||||
'get_tagged_file_groups_by_ids', 'delete_file_groups']
|
||||
|
||||
|
||||
def get_file_tag_names(session: Session, file: pathlib.Path) -> List[str]:
|
||||
|
|
@ -219,7 +219,7 @@ def list_directories_contents(session: Session, directories_: List[str]) -> Dict
|
|||
def get_tagged_file_groups_by_ids(session: Session, file_group_ids: List[int]) -> List[dict]:
|
||||
"""Return serialized FileGroups (subset of `file_group_ids`) that have at least one TagFile.
|
||||
|
||||
Used by Archive/Video/Doc deletion endpoints to require force-confirmation before deleting tagged files."""
|
||||
Used by the unified delete endpoint to require force-confirmation before deleting tagged files."""
|
||||
if not file_group_ids:
|
||||
return []
|
||||
rows = session.query(FileGroup) \
|
||||
|
|
@ -230,6 +230,92 @@ def get_tagged_file_groups_by_ids(session: Session, file_group_ids: List[int]) -
|
|||
return [fg.__json__() for fg in rows]
|
||||
|
||||
|
||||
def get_tagged_file_groups_by_paths(session: Session, paths: Iterable[pathlib.Path]) -> List[FileGroup]:
|
||||
"""Return FileGroup objects under any of the given paths that have at least one TagFile.
|
||||
|
||||
For a file path, matches FileGroups whose primary_path shares the file's stem in the same
|
||||
directory. For a directory path, matches FileGroups whose `directory` is the path or a
|
||||
descendant. Used by the path-based delete endpoint to require force-confirmation before
|
||||
deleting tagged files."""
|
||||
paths = list(paths)
|
||||
if not paths:
|
||||
return []
|
||||
tagged: List[FileGroup] = []
|
||||
seen_ids = set()
|
||||
for path in paths:
|
||||
query = session.query(FileGroup) \
|
||||
.join(TagFile, TagFile.file_group_id == FileGroup.id)
|
||||
if path.is_file():
|
||||
# Could be deleting a file in a FileGroup that has been tagged.
|
||||
stem, _ = split_path_stem_and_suffix(path)
|
||||
query = query.filter(FileGroup.primary_path.like(f'{path.parent}/{stem}%'))
|
||||
else:
|
||||
# Search for any FileGroups that have been tagged under this directory.
|
||||
# Use indexed directory column for efficient lookup.
|
||||
path_str = str(path)
|
||||
query = query.filter(or_(
|
||||
FileGroup.directory == path_str,
|
||||
FileGroup.directory.like(f'{path_str}/%'),
|
||||
))
|
||||
for file_group in query:
|
||||
if path.is_dir() or any(p for p in paths if p in file_group.my_paths()):
|
||||
if file_group.id not in seen_ids:
|
||||
seen_ids.add(file_group.id)
|
||||
tagged.append(file_group)
|
||||
return tagged
|
||||
|
||||
|
||||
def delete_file_groups(session: Session, file_group_ids: List[int], force: bool = False) -> Set[str]:
|
||||
"""Delete FileGroups (and their model rows via FK CASCADE) by ID.
|
||||
|
||||
Returns the set of `FileGroup.model` values that were deleted, so the caller can fire any
|
||||
model-specific post-delete switches (e.g., `save_channels_config` for videos).
|
||||
|
||||
The caller is expected to have already gated this on tag confirmation via
|
||||
`get_tagged_file_groups_by_ids`. `force` propagates to FileGroup.delete so the safety net
|
||||
at FileGroup.delete (raises FileGroupIsTagged if force is False and tags exist) still
|
||||
protects callers that skipped the pre-check."""
|
||||
files = FileGroup.get_by_ids(session, file_group_ids)
|
||||
if not files:
|
||||
raise UnknownFile(f'No FileGroups found for ids: {file_group_ids}')
|
||||
|
||||
affected_models: Set[str] = {fg.model for fg in files if fg.model}
|
||||
had_tagged = any(fg.tag_names for fg in files)
|
||||
|
||||
# Capture domain Collection IDs for any Archive FileGroups so we can prune empty ones after
|
||||
# the cascade removes the Archive rows. (Archive.delete used to do this per-archive; with
|
||||
# cascade-based deletion we do it as a single post-pass.)
|
||||
archive_collection_ids: Set[int] = set()
|
||||
if 'archive' in affected_models:
|
||||
from modules.archive.models import Archive
|
||||
archive_collection_ids = {
|
||||
collection_id for (collection_id,) in session.query(Archive.collection_id)
|
||||
.filter(Archive.file_group_id.in_([fg.id for fg in files]))
|
||||
if collection_id is not None
|
||||
}
|
||||
|
||||
for fg in files:
|
||||
fg.delete(force=force)
|
||||
|
||||
if archive_collection_ids:
|
||||
# Flush so the FK CASCADE has removed the Archive rows before we count remaining.
|
||||
session.flush()
|
||||
from modules.archive.models import Archive
|
||||
from wrolpi.collections.models import Collection
|
||||
for collection_id in archive_collection_ids:
|
||||
collection = session.query(Collection).filter_by(id=collection_id, kind='domain').one_or_none()
|
||||
if collection is None:
|
||||
continue
|
||||
remaining = session.query(Archive).filter_by(collection_id=collection_id).count()
|
||||
if remaining == 0:
|
||||
session.delete(collection)
|
||||
|
||||
if had_tagged:
|
||||
save_tags_config.activate_switch()
|
||||
|
||||
return affected_models
|
||||
|
||||
|
||||
@wrol_mode_check
|
||||
async def delete(*paths: Union[str, pathlib.Path], force: bool = False):
|
||||
"""
|
||||
|
|
@ -251,34 +337,16 @@ async def delete(*paths: Union[str, pathlib.Path], force: bool = False):
|
|||
continue
|
||||
if p1 != p2 and p1 in p2.parents:
|
||||
raise InvalidFile(f'Cannot deleted nested paths')
|
||||
if not force:
|
||||
tagged_file_groups = []
|
||||
seen_ids = set()
|
||||
with get_db_session() as session:
|
||||
# Search for any files that have been tagged.
|
||||
for path in paths:
|
||||
query = session.query(FileGroup, TagFile) \
|
||||
.join(TagFile, TagFile.file_group_id == FileGroup.id)
|
||||
if path.is_file():
|
||||
# Could be deleting a file in a FileGroup that has been tagged.
|
||||
stem, _ = split_path_stem_and_suffix(path)
|
||||
query = query.filter(FileGroup.primary_path.like(f'{path.parent}/{stem}%'))
|
||||
else:
|
||||
# Search for any FileGroups that have been tagged under this directory.
|
||||
# Use indexed directory column for efficient lookup
|
||||
path_str = str(path)
|
||||
query = query.filter(or_(
|
||||
FileGroup.directory == path_str,
|
||||
FileGroup.directory.like(f'{path_str}/%')
|
||||
))
|
||||
for (file_group, tag_file) in query:
|
||||
if path.is_dir() or any(i for i in paths if i in file_group.my_paths()):
|
||||
if file_group.id not in seen_ids:
|
||||
seen_ids.add(file_group.id)
|
||||
tagged_file_groups.append(file_group)
|
||||
if tagged_file_groups:
|
||||
# Serialize while session is still active.
|
||||
return [i.__json__() for i in tagged_file_groups]
|
||||
|
||||
# Discover any tagged FileGroups under these paths so we can either gate the deletion (when
|
||||
# !force) or fire `save_tags_config` afterwards (when force=True and any were tagged).
|
||||
with get_db_session() as session:
|
||||
tagged_file_groups = get_tagged_file_groups_by_paths(session, paths)
|
||||
if tagged_file_groups and not force:
|
||||
# Serialize while session is still active.
|
||||
return [i.__json__() for i in tagged_file_groups]
|
||||
had_tagged = bool(tagged_file_groups)
|
||||
|
||||
for path in paths:
|
||||
ignored_directories = get_wrolpi_config().ignored_directories
|
||||
if ignored_directories and str(path) in ignored_directories:
|
||||
|
|
@ -294,6 +362,10 @@ async def delete(*paths: Union[str, pathlib.Path], force: bool = False):
|
|||
from wrolpi.files.worker import file_worker
|
||||
await file_worker.refresh_sync(list(paths))
|
||||
|
||||
if had_tagged:
|
||||
# `tags.yaml` references TagFiles that the refresh just removed; rewrite it.
|
||||
save_tags_config.activate_switch()
|
||||
|
||||
|
||||
def _mimetype_suffix_map(path: Path, mimetype: str):
|
||||
"""Special handling for mimetypes. Python Magic may not return the correct mimetype."""
|
||||
|
|
@ -1529,17 +1601,9 @@ def delete_directory(directory: pathlib.Path, recursive: bool = False, force: bo
|
|||
if recursive:
|
||||
if not force:
|
||||
with get_db_session() as session:
|
||||
# Use indexed directory column for efficient lookup
|
||||
directory_str = str(directory)
|
||||
tagged = session.query(FileGroup) \
|
||||
.filter(or_(
|
||||
FileGroup.directory == directory_str,
|
||||
FileGroup.directory.like(f'{directory_str}/%')
|
||||
)) \
|
||||
.join(TagFile, TagFile.file_group_id == FileGroup.id) \
|
||||
.limit(1).one_or_none()
|
||||
tagged = get_tagged_file_groups_by_paths(session, [directory])
|
||||
if tagged:
|
||||
raise FileGroupIsTagged(f'Cannot delete {tagged} because it is tagged')
|
||||
raise FileGroupIsTagged(f'Cannot delete {tagged[0]} because it is tagged')
|
||||
shutil.rmtree(directory)
|
||||
else:
|
||||
directory.rmdir()
|
||||
|
|
@ -1733,7 +1797,7 @@ async def upsert_file(file: pathlib.Path | str, tag_names: List[str] = None) ->
|
|||
# Mark download as completed
|
||||
download.complete()
|
||||
# Link the download to the location of the uploaded file.
|
||||
model = file_group.get_model_record()
|
||||
model = file_group.get_model_record(session)
|
||||
download.location = model.location if model else file_group.location
|
||||
|
||||
# Add tags if provided
|
||||
|
|
|
|||
|
|
@ -544,6 +544,13 @@ class FileGroup(ModelHelper, Base):
|
|||
raise UnknownFile(f'Unable to find FileGroup with id {id_}')
|
||||
return fg
|
||||
|
||||
@classmethod
|
||||
def get_by_ids(cls, session: Session, file_group_ids: List[int]) -> List['FileGroup']:
|
||||
"""Return FileGroups matching the given IDs (no error if some are missing)."""
|
||||
if not file_group_ids:
|
||||
return []
|
||||
return session.query(cls).filter(cls.id.in_(file_group_ids)).all()
|
||||
|
||||
@staticmethod
|
||||
def get_by_path(session: Session, path) -> Optional['FileGroup']:
|
||||
file_group = session.query(FileGroup).filter(FileGroup.primary_path == str(path)).one_or_none()
|
||||
|
|
@ -741,12 +748,10 @@ class FileGroup(ModelHelper, Base):
|
|||
elif Zim.can_model(self):
|
||||
return Zim
|
||||
|
||||
def get_model_record(self) -> Optional[ModelHelper]:
|
||||
def get_model_record(self, session: Session) -> Optional[ModelHelper]:
|
||||
"""Return this FileGroup's model record (Video/Archive/Doc/Zim) using the given session, or None."""
|
||||
klass = self.get_model_class()
|
||||
if klass:
|
||||
with get_db_session() as session:
|
||||
return klass.get_by_path(session, self.primary_path)
|
||||
return None
|
||||
return klass.get_by_path(session, self.primary_path) if klass else None
|
||||
|
||||
def do_model(self, session: Session) -> Optional[ModelHelper]:
|
||||
"""Get/Create the Model record (Video/Archive/etc.) for this FileGroup, if any."""
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ class DeleteRequest:
|
|||
force: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteFileGroupsRequest:
|
||||
file_group_ids: List[int] = field(default_factory=list)
|
||||
force: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilesSearchRequest:
|
||||
search_str: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -163,6 +163,63 @@ async def test_delete_wrol_mode(async_client, wrol_mode_fixture):
|
|||
assert response.json['code'] == 'WROL_MODE_ENABLED'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_groups_mixed_batch(async_client, test_session, video_factory, archive_factory):
|
||||
"""A single POST /api/files/delete_groups can delete a mixture of model types in one call."""
|
||||
from modules.archive.models import Archive
|
||||
video = video_factory(with_video_file=True)
|
||||
archive = archive_factory('example.com', 'https://example.com/1')
|
||||
test_session.commit()
|
||||
video_paths = list(video.file_group.my_paths())
|
||||
archive_paths = list(archive.my_paths())
|
||||
|
||||
body = json.dumps({'file_group_ids': [video.file_group_id, archive.file_group_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
assert all(not p.exists() for p in video_paths)
|
||||
assert all(not p.exists() for p in archive_paths)
|
||||
assert test_session.query(Video).count() == 0
|
||||
assert test_session.query(Archive).count() == 0
|
||||
assert test_session.query(FileGroup).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_groups_default_deleter(async_client, test_session, make_files_structure,
|
||||
refresh_files):
|
||||
"""A FileGroup with no specialized model (e.g., a plain text file) is removed via FileGroup.delete."""
|
||||
foo, = make_files_structure({'foo.txt': 'hello'})
|
||||
await refresh_files()
|
||||
|
||||
fg = test_session.query(FileGroup).one()
|
||||
assert fg.model is None # plain text isn't modeled by Video/Archive/Doc/Zim
|
||||
fg_id = fg.id
|
||||
|
||||
body = json.dumps({'file_group_ids': [fg_id]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT
|
||||
|
||||
assert not foo.exists()
|
||||
assert test_session.query(FileGroup).filter_by(id=fg_id).one_or_none() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_groups_unknown_ids(async_client, test_session):
|
||||
"""Deleting non-existent IDs returns 404 from UnknownFile."""
|
||||
body = json.dumps({'file_group_ids': [99999]})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json['code'] == 'UNKNOWN_FILE'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_groups_empty_ids(async_client):
|
||||
"""An empty file_group_ids list returns 400 (InvalidFile)."""
|
||||
body = json.dumps({'file_group_ids': []})
|
||||
request, response = await async_client.post('/api/files/delete_groups', content=body)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_search_recent(test_session, test_directory, async_client, video_file_factory, refresh_files):
|
||||
"""File search can return the most recently viewed files."""
|
||||
|
|
@ -712,7 +769,7 @@ async def test_post_upload(test_session, async_client, test_directory, make_file
|
|||
assert not file_group.tag_names, 'No tags this time.'
|
||||
assert file_group.indexed, 'File should be indexed after upload.'
|
||||
assert file_group.model, 'File should be modeled'
|
||||
assert isinstance(file_group.get_model_record(), Video)
|
||||
assert isinstance(file_group.get_model_record(test_session), Video)
|
||||
assert file_group.location == f'/videos/{file_group.id}'
|
||||
# Video was modeled, but has no Channel.
|
||||
video: Video = test_session.query(Video).one()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue