diff --git a/app/src/components/Channels.js b/app/src/components/Channels.js index b776a687..345ec301 100644 --- a/app/src/components/Channels.js +++ b/app/src/components/Channels.js @@ -757,7 +757,7 @@ export function ChannelsPage() { const headers = [ {key: 'name', text: 'Name', sortBy: 'name', width: 8}, - {key: 'tag', text: 'Tag', sortBy: 'tag', width: 2}, + {key: 'tag', text: 'Tag', sortBy: 'tag_name', width: 2}, {key: 'video_count', text: 'Videos', sortBy: 'video_count', width: 2}, {key: 'download_frequency', text: 'Download Frequency', sortBy: 'minimum_frequency', width: 2}, {key: 'size', text: 'Size', sortBy: 'size', width: 2}, diff --git a/modules/videos/channel/lib.py b/modules/videos/channel/lib.py index bb3648b3..ed7ee5ee 100644 --- a/modules/videos/channel/lib.py +++ b/modules/videos/channel/lib.py @@ -5,11 +5,12 @@ from typing import List, Dict, Union from sqlalchemy import or_, func, desc, asc from sqlalchemy.orm import Session +from wrolpi import flags from wrolpi.common import run_after, logger, \ get_media_directory, wrol_mode_check from wrolpi.db import get_db_curs, optional_session, get_db_session from wrolpi.downloader import save_downloads_config, download_manager -from wrolpi.errors import UnknownDirectory, APIError, ValidationError +from wrolpi.errors import UnknownDirectory, APIError, ValidationError, RefreshConflict from .. import schema from ..common import check_for_channel_conflicts from ..errors import UnknownChannel, ChannelDirectoryConflict @@ -257,6 +258,9 @@ async def tag_channel(tag_name: str, directory: pathlib.Path | None, channel_id: Move the Channel to the new directory, if provided.""" from wrolpi.tags import Tag + if directory and flags.refreshing.is_set(): + raise RefreshConflict('Refusing to move channel while file refresh is in progress') + channel = Channel.find_by_id(channel_id, session=session) if tag_name: diff --git a/wrolpi/errors.py b/wrolpi/errors.py index 34e10f85..05985d29 100644 --- a/wrolpi/errors.py +++ b/wrolpi/errors.py @@ -169,3 +169,9 @@ class InvalidDirectory(APIError): code = 'INVALID_DIRECTORY' message = 'Directory is not valid or impossible' status_code = HTTPStatus.BAD_REQUEST + + +class RefreshConflict(APIError): + code = 'REFRESH_CONFLICT' + message = 'Not possible during file refresh' + status_code = HTTPStatus.CONFLICT diff --git a/wrolpi/tags.py b/wrolpi/tags.py index 14bd63eb..a824d538 100644 --- a/wrolpi/tags.py +++ b/wrolpi/tags.py @@ -7,12 +7,12 @@ from sqlalchemy import Column, Integer, String, ForeignKey, BigInteger from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import relationship, Session -from wrolpi import dates +from wrolpi import dates, flags from wrolpi.common import ModelHelper, Base, logger, ConfigFile, get_media_directory, background_task, run_after, \ - register_refresh_cleanup, get_relative_to_media_directory, is_valid_hex_color, walk, INVALID_FILE_CHARS + get_relative_to_media_directory, is_valid_hex_color, walk, INVALID_FILE_CHARS from wrolpi.dates import TZDateTime from wrolpi.db import optional_session, get_db_curs -from wrolpi.errors import UnknownTag, UsedTag, InvalidTag, FileGroupAlreadyTagged +from wrolpi.errors import UnknownTag, UsedTag, InvalidTag, FileGroupAlreadyTagged, RefreshConflict from wrolpi.vars import PYTEST logger = logger.getChild(__name__) @@ -150,6 +150,26 @@ class Tag(ModelHelper, Base): """Returns True if this Tag has been used with any FileGroups or Zim Entries.""" return bool(self.tag_files or self.tag_zim_entries) + def update_tag(self, name: str, color: str, session: Session): + """Change name/color of tag. Ensures safeness of new values, checks for conflicts.""" + if ',' in name: + raise InvalidTag('Tag name cannot have comma') + if not is_valid_hex_color(color): + raise InvalidTag('Tag color is invalid') + + # Replace forward-slash (linux directories) with unicode Big Solidus (U+29F8) + name = name.replace('/', '⧸') + name = INVALID_FILE_CHARS.sub('', name) + + if Tag.get_by_name(name, session): + raise InvalidTag('Tag name already taken') + + if flags.refreshing.is_set(): + raise RefreshConflict('Refusing to rename tag while file refresh is in progress') + + self.name = name + self.color = color + class TagsConfig(ConfigFile): file_name = 'tags.yaml' @@ -375,27 +395,16 @@ def get_tags() -> List[dict]: @optional_session @run_after(save_tags_config) def upsert_tag(name: str, color: str, tag_id: int = None, session: Session = None) -> Tag: - if ',' in name: - raise InvalidTag('Tag name cannot have comma') - if not is_valid_hex_color(color): - raise InvalidTag('Tag color is invalid') - - # Replace forward-slash (linux directories) with unicode Big Solidus (U+29F8) - name = name.replace('/', '⧸') - name = INVALID_FILE_CHARS.sub('', name) - if tag_id: - tag = session.query(Tag).filter_by(id=tag_id).one_or_none() - if not tag: - raise UnknownTag(f'Cannot find tag with id={tag_id}') - tag.name = name - tag.color = color + tag = Tag.find_by_id(tag_id, session) + tag.update_tag(name, color, session) + tag.flush() else: - tag = Tag(name=name, color=color) + tag = Tag() + tag.update_tag(name, color, session) session.add(tag) try: - session.flush([tag]) session.commit() except IntegrityError as e: # Conflicting name @@ -410,14 +419,10 @@ def upsert_tag(name: str, color: str, tag_id: int = None, session: Session = Non @optional_session def delete_tag(tag_id: int, session: Session = None): - tag: Tag = session.query(Tag).filter_by(id=tag_id).one_or_none() + tag = Tag.find_by_id(tag_id, session) - if not tag: - raise UnknownTag(f'Cannot find tag {tag_id}') - - if tag.tag_files: - count = len(tag.tag_files) - raise UsedTag(f'Cannot delete {tag.name} it is used by {count} files!') + if tag.has_relations(): + raise UsedTag(f'Cannot delete {tag.name} it is used') session.delete(tag) session.commit() diff --git a/wrolpi/test/test_tags.py b/wrolpi/test/test_tags.py index 8238380c..2356bee6 100644 --- a/wrolpi/test/test_tags.py +++ b/wrolpi/test/test_tags.py @@ -339,6 +339,7 @@ async def test_invalid_tag(test_async_client, test_session, test_directory): @pytest.mark.asyncio async def test_tags_directory(test_async_client, test_session, test_directory, tag_factory, video_factory, example_pdf): + """Test that Tag Directory is synchronized with database.""" readme = test_directory / 'tags/README.txt' # Should be deleted on sync. @@ -436,3 +437,26 @@ async def test_tags_directory(test_async_client, test_session, test_directory, t # Only three FileGroups were created. Tags Directory files are ignored during refresh. await files_lib.refresh_files() assert test_session.query(FileGroup).count() == 3 + + +@pytest.mark.asyncio +async def test_update_tag(test_async_client, test_session, test_directory, video_factory, tag_factory): + """Linked files in the Tag Directory are moved when the tag is renamed.""" + # Create tagged Video. + tag = tag_factory() + video_factory(title='video', tag_names=[tag.name]) + test_session.commit() + + # Video file is linked in tag directory. + assert (test_directory / 'tags').is_dir() + assert (test_directory / 'videos/NO CHANNEL/video.mp4').is_file() + assert (test_directory / 'tags/one/video.mp4').is_file() + + # Invalid characters are replaced during rename. + tags.upsert_tag('new/name%', tag.color, tag.id) + + # Video file was moved. + assert (test_directory / 'tags').is_dir() + assert (test_directory / 'videos/NO CHANNEL/video.mp4').is_file() + assert not (test_directory / 'tags/one/video.mp4').exists() + assert (test_directory / 'tags/new⧸name/video.mp4').is_file()