Consolidating deletion endpoints.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled

This commit is contained in:
Roland 2026-05-03 18:50:22 -06:00
parent 09a967535b
commit ba63170e55
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
24 changed files with 309 additions and 320 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
};

View file

@ -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);
});
});

View file

@ -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)

View file

@ -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')

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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):

View file

@ -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']}

View file

@ -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.'

View file

@ -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']}

View file

@ -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()

View file

@ -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(

View file

@ -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'

View file

@ -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.',

View file

@ -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

View file

@ -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."""

View file

@ -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

View file

@ -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()