2018-02-17 05:28:36 +00:00
|
|
|
'''
|
|
|
|
This file provides the data objects that should not be instantiated directly,
|
|
|
|
but are returned by the PDB accesses.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
import os
|
|
|
|
import PIL.Image
|
2018-03-23 06:39:11 +00:00
|
|
|
import string
|
2016-12-17 04:02:08 +00:00
|
|
|
import traceback
|
|
|
|
|
2018-11-05 03:27:20 +00:00
|
|
|
from voussoirkit import bytestring
|
|
|
|
from voussoirkit import pathclass
|
|
|
|
from voussoirkit import spinal
|
2019-01-02 02:08:47 +00:00
|
|
|
from voussoirkit import sqlhelpers
|
2018-11-05 03:27:20 +00:00
|
|
|
|
2017-02-05 03:55:13 +00:00
|
|
|
from . import constants
|
|
|
|
from . import decorators
|
|
|
|
from . import exceptions
|
|
|
|
from . import helpers
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-02-25 06:07:59 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
class ObjectBase:
|
2017-04-23 04:16:30 +00:00
|
|
|
def __init__(self, photodb):
|
|
|
|
super().__init__()
|
|
|
|
self.photodb = photodb
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def __eq__(self, other):
|
|
|
|
return (
|
|
|
|
isinstance(other, type(self)) and
|
|
|
|
self.photodb == other.photodb and
|
|
|
|
self.id == other.id
|
|
|
|
)
|
|
|
|
|
|
|
|
def __format__(self, formcode):
|
|
|
|
if formcode == 'r':
|
|
|
|
return repr(self)
|
|
|
|
else:
|
|
|
|
return str(self)
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
return hash(self.id)
|
|
|
|
|
2018-03-23 06:38:10 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_author_id(author_id):
|
2018-04-15 21:23:24 +00:00
|
|
|
if author_id is None:
|
2018-03-23 06:38:10 +00:00
|
|
|
return None
|
|
|
|
|
|
|
|
if not isinstance(author_id, str):
|
2018-07-19 01:36:36 +00:00
|
|
|
raise TypeError(f'Author ID must be string, not {type(author_id)}.')
|
2018-03-23 06:38:10 +00:00
|
|
|
|
2018-04-15 21:23:24 +00:00
|
|
|
author_id = author_id.strip()
|
|
|
|
if author_id == '':
|
|
|
|
return None
|
|
|
|
|
2018-03-23 06:38:10 +00:00
|
|
|
return author_id
|
|
|
|
|
2018-03-18 22:58:51 +00:00
|
|
|
def get_author(self):
|
|
|
|
'''
|
|
|
|
Return the User who created this object, or None if it is unassigned.
|
|
|
|
'''
|
|
|
|
if self.author_id is None:
|
|
|
|
return None
|
|
|
|
return self.photodb.get_user(id=self.author_id)
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
class GroupableMixin:
|
2017-03-04 09:13:22 +00:00
|
|
|
group_getter = None
|
2018-04-15 09:14:06 +00:00
|
|
|
group_getter_many = None
|
2017-03-04 09:13:22 +00:00
|
|
|
group_sql_index = None
|
|
|
|
group_table = None
|
|
|
|
|
2018-05-04 01:32:44 +00:00
|
|
|
def _lift_children(self):
|
|
|
|
'''
|
2018-07-20 05:42:21 +00:00
|
|
|
If this object has parents, the parents adopt all of its children.
|
|
|
|
Otherwise the parental relationship is simply deleted.
|
2018-05-04 01:32:44 +00:00
|
|
|
'''
|
2018-07-20 05:42:21 +00:00
|
|
|
children = self.get_children()
|
|
|
|
if not children:
|
|
|
|
return
|
|
|
|
|
|
|
|
self.photodb.sql_delete(table=self.group_table, pairs={'parentid': self.id})
|
|
|
|
|
|
|
|
parents = self.get_parents()
|
|
|
|
for parent in parents:
|
|
|
|
parent.add_children(children)
|
2018-05-04 01:32:44 +00:00
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-11-12 06:41:26 +00:00
|
|
|
def add_child(self, member, *, commit=True):
|
2018-07-20 05:42:21 +00:00
|
|
|
self.assert_same_type(member)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-09-23 06:10:18 +00:00
|
|
|
if member == self:
|
|
|
|
raise exceptions.CantGroupSelf(self)
|
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
if self.has_child(member):
|
|
|
|
return
|
2017-03-10 23:27:40 +00:00
|
|
|
|
2018-07-29 23:01:26 +00:00
|
|
|
self.photodb.log.debug('Adding child %s to %s.', member, self)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-10 23:27:40 +00:00
|
|
|
for my_ancestor in self.walk_parents():
|
|
|
|
if my_ancestor == member:
|
2017-03-05 05:56:23 +00:00
|
|
|
raise exceptions.RecursiveGrouping(member=member, group=self)
|
2016-12-21 02:31:09 +00:00
|
|
|
|
2018-02-17 04:19:18 +00:00
|
|
|
data = {
|
|
|
|
'parentid': self.id,
|
|
|
|
'memberid': member.id,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_insert(table=self.group_table, data=data)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb._cached_frozen_children = None
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2017-02-05 02:30:02 +00:00
|
|
|
self.photodb.log.debug('Committing - add to group')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
@decorators.transaction
|
|
|
|
def add_children(self, members, *, commit=True):
|
|
|
|
for member in members:
|
|
|
|
self.add_child(member, commit=False)
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add multiple to group')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def assert_same_type(self, other):
|
|
|
|
if not isinstance(other, type(self)):
|
|
|
|
raise TypeError(f'Object must be of type {type(self)}, not {type(other)}.')
|
|
|
|
if self.photodb != other.photodb:
|
|
|
|
raise TypeError(f'Objects must belong to the same PhotoDB.')
|
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_children=False, commit=True):
|
|
|
|
'''
|
|
|
|
Delete this object's relationships to other groupables.
|
|
|
|
Any unique / specific deletion methods should be written within the
|
|
|
|
inheriting class.
|
|
|
|
|
|
|
|
For example, Tag.delete calls here to remove the group links, but then
|
|
|
|
does the rest of the tag deletion process on its own.
|
|
|
|
|
|
|
|
delete_children:
|
|
|
|
If True, all children will be deleted.
|
|
|
|
Otherwise they'll just be raised up one level.
|
|
|
|
'''
|
|
|
|
self.photodb._cached_frozen_children = None
|
|
|
|
if delete_children:
|
2018-02-17 07:00:43 +00:00
|
|
|
for child in self.get_children():
|
2018-07-20 05:42:21 +00:00
|
|
|
child.delete(delete_children=True, commit=False)
|
2016-12-17 04:02:08 +00:00
|
|
|
else:
|
2018-05-04 01:32:44 +00:00
|
|
|
self._lift_children()
|
2018-02-17 22:02:11 +00:00
|
|
|
|
2017-02-25 06:07:59 +00:00
|
|
|
# Note that this part comes after the deletion of children to prevent
|
|
|
|
# issues of recursion.
|
2018-02-26 00:17:19 +00:00
|
|
|
self.photodb.sql_delete(table=self.group_table, pairs={'memberid': self.id})
|
2017-05-02 04:16:10 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-02-17 07:00:43 +00:00
|
|
|
def get_children(self):
|
2018-03-27 03:07:42 +00:00
|
|
|
child_rows = self.photodb.sql_select(
|
2018-07-19 01:36:36 +00:00
|
|
|
f'SELECT memberid FROM {self.group_table} WHERE parentid == ?',
|
2018-03-27 03:07:42 +00:00
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
child_ids = [row[0] for row in child_rows]
|
2018-04-15 09:14:06 +00:00
|
|
|
children = self.group_getter_many(child_ids)
|
2018-03-27 03:07:42 +00:00
|
|
|
|
2018-02-17 07:00:43 +00:00
|
|
|
if isinstance(self, Tag):
|
2018-04-15 09:14:06 +00:00
|
|
|
children = sorted(children, key=lambda x: x.name)
|
2018-02-17 07:00:43 +00:00
|
|
|
else:
|
2018-04-15 09:14:06 +00:00
|
|
|
children = sorted(children, key=lambda x: x.id)
|
2018-03-27 03:07:42 +00:00
|
|
|
return children
|
2018-02-17 07:00:43 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
def get_parents(self):
|
|
|
|
query = f'SELECT parentid FROM {self.group_table} WHERE memberid == ?'
|
|
|
|
parent_rows = self.photodb.sql_select(query, [self.id])
|
|
|
|
parent_ids = [row[0] for row in parent_rows]
|
|
|
|
parents = list(self.group_getter_many(parent_ids))
|
|
|
|
return parents
|
|
|
|
|
|
|
|
def has_any_child(self):
|
|
|
|
query = f'SELECT 1 FROM {self.group_table} WHERE parentid == ? LIMIT 1'
|
|
|
|
row = self.photodb.sql_select_one(query, [self.id])
|
|
|
|
return row is not None
|
|
|
|
|
|
|
|
def has_any_parent(self):
|
|
|
|
query = f'SELECT 1 FROM {self.group_table} WHERE memberid == ? LIMIT 1'
|
|
|
|
row = self.photodb.sql_select_one(query, [self.id])
|
|
|
|
return row is not None
|
|
|
|
|
|
|
|
def has_child(self, member):
|
|
|
|
self.assert_same_type(member)
|
|
|
|
query = f'SELECT 1 FROM {self.group_table} WHERE parentid == ? AND memberid == ?'
|
|
|
|
row = self.photodb.sql_select_one(query, [self.id, member.id])
|
|
|
|
return row is not None
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2018-07-20 05:42:21 +00:00
|
|
|
def remove_child(self, member, *, commit=True):
|
|
|
|
if not self.has_child(member):
|
2018-05-04 01:18:13 +00:00
|
|
|
return
|
|
|
|
|
2018-07-29 23:01:26 +00:00
|
|
|
self.photodb.log.debug('Removing child %s from %s.', member, self)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
pairs = {
|
|
|
|
'parentid': self.id,
|
|
|
|
'memberid': member.id,
|
|
|
|
}
|
|
|
|
self.photodb.sql_delete(table=self.group_table, pairs=pairs)
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb._cached_frozen_children = None
|
2018-07-20 05:42:21 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2018-07-20 05:42:21 +00:00
|
|
|
self.photodb.log.debug('Committing - remove from group')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
def walk_children(self):
|
|
|
|
yield self
|
2018-02-17 07:00:43 +00:00
|
|
|
for child in self.get_children():
|
2016-12-17 04:02:08 +00:00
|
|
|
yield from child.walk_children()
|
|
|
|
|
|
|
|
def walk_parents(self):
|
2018-07-20 05:42:21 +00:00
|
|
|
'''
|
|
|
|
Yield all ancestors in no particular order.
|
|
|
|
'''
|
|
|
|
parents = self.get_parents()
|
|
|
|
seen = set(parents)
|
|
|
|
todo = list(parents)
|
|
|
|
while len(todo) > 0:
|
|
|
|
parent = todo.pop(-1)
|
2016-12-17 04:02:08 +00:00
|
|
|
yield parent
|
2018-07-20 05:42:21 +00:00
|
|
|
seen.add(parent)
|
|
|
|
more_parents = set(parent.get_parents())
|
|
|
|
more_parents = more_parents.difference(seen)
|
|
|
|
todo.extend(more_parents)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
|
|
|
|
class Album(ObjectBase, GroupableMixin):
|
2017-03-04 09:13:22 +00:00
|
|
|
group_table = 'album_group_rel'
|
2018-02-17 04:40:58 +00:00
|
|
|
group_sql_index = constants.SQL_INDEX[group_table]
|
2017-03-04 09:13:22 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2018-02-17 04:40:58 +00:00
|
|
|
db_row = dict(zip(constants.SQL_COLUMNS['albums'], db_row))
|
2017-11-12 06:41:26 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
2018-03-23 06:39:11 +00:00
|
|
|
self.title = self.normalize_title(db_row['title'])
|
|
|
|
self.description = self.normalize_description(db_row['description'])
|
2018-03-23 06:38:10 +00:00
|
|
|
self.author_id = self.normalize_author_id(db_row['author_id'])
|
2018-03-18 22:28:26 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
self.group_getter = self.photodb.get_album
|
2018-04-15 09:14:06 +00:00
|
|
|
self.group_getter_many = self.photodb.get_albums_by_id
|
2017-11-12 23:21:53 +00:00
|
|
|
|
|
|
|
self._sum_bytes_local = None
|
|
|
|
self._sum_bytes_recursive = None
|
|
|
|
self._sum_photos_recursive = None
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
2018-04-15 08:13:02 +00:00
|
|
|
return f'Album:{self.id}'
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-23 06:39:11 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_description(description):
|
|
|
|
if description is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(description, str):
|
2018-07-20 05:42:21 +00:00
|
|
|
raise TypeError(f'Description must be string, not {type(description)}')
|
2018-03-23 06:39:11 +00:00
|
|
|
|
|
|
|
description = description.strip()
|
|
|
|
|
|
|
|
return description
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def normalize_title(title):
|
|
|
|
if title is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(title, str):
|
2018-07-20 05:42:21 +00:00
|
|
|
raise TypeError(f'Title must be string, not {type(title)}')
|
2018-03-23 06:39:11 +00:00
|
|
|
|
|
|
|
title = title.strip()
|
|
|
|
for whitespace in string.whitespace:
|
|
|
|
title = title.replace(whitespace, ' ')
|
|
|
|
|
|
|
|
return title
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['album'].remove(self.id)
|
2017-11-12 23:21:53 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
2018-02-26 02:55:46 +00:00
|
|
|
# GroupableMixin.add_child already has @transaction.
|
2017-11-12 06:41:26 +00:00
|
|
|
def add_child(self, *args, **kwargs):
|
2018-07-23 04:06:16 +00:00
|
|
|
return super().add_child(*args, **kwargs)
|
2017-06-15 06:15:47 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
# GroupableMixin.add_children already has @transaction.
|
|
|
|
def add_children(self, *args, **kwargs):
|
|
|
|
return super().add_children(*args, **kwargs)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2018-07-29 23:30:30 +00:00
|
|
|
def add_associated_directory(self, path, *, commit=True):
|
2017-11-12 06:49:03 +00:00
|
|
|
'''
|
|
|
|
Add a directory from which this album will pull files during rescans.
|
|
|
|
These relationships are not unique and multiple albums
|
|
|
|
can associate with the same directory if desired.
|
|
|
|
'''
|
2018-07-29 23:30:30 +00:00
|
|
|
path = pathclass.Path(path)
|
|
|
|
|
|
|
|
if not path.is_dir:
|
|
|
|
raise ValueError(f'{path} is not a directory.')
|
|
|
|
|
|
|
|
if self.has_associated_directory(path):
|
2017-05-02 03:43:45 +00:00
|
|
|
return
|
|
|
|
|
2018-07-29 23:30:30 +00:00
|
|
|
self.photodb.log.debug('Adding directory %s to %s.', path, self)
|
|
|
|
data = {'albumid': self.id, 'directory': path.absolute_path}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_insert(table='album_associated_directories', data=data)
|
2017-05-02 03:43:45 +00:00
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add associated directory')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-03-11 06:50:41 +00:00
|
|
|
def _add_photo(self, photo):
|
|
|
|
self.photodb.log.debug('Adding photo %s to %s', photo, self)
|
2018-05-02 00:48:56 +00:00
|
|
|
data = {'albumid': self.id, 'photoid': photo.id}
|
2018-03-11 06:50:41 +00:00
|
|
|
self.photodb.sql_insert(table='album_photo_rel', data=data)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_photo(self, photo, *, commit=True):
|
|
|
|
if self.has_photo(photo):
|
|
|
|
return
|
2018-01-12 03:32:15 +00:00
|
|
|
|
2018-03-11 06:50:41 +00:00
|
|
|
self._add_photo(photo)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add photo to album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-03-11 06:50:41 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def add_photos(self, photos, *, commit=True):
|
|
|
|
existing_photos = set(self.get_photos())
|
|
|
|
photos = set(photos)
|
|
|
|
photos = photos.difference(existing_photos)
|
|
|
|
|
|
|
|
for photo in photos:
|
|
|
|
self._add_photo(photo)
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add photos to album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-02-26 02:55:46 +00:00
|
|
|
# Photo.add_tag already has @required_feature
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_tag_to_all(self, tag, *, nested_children=True, commit=True):
|
2017-03-10 23:01:12 +00:00
|
|
|
'''
|
|
|
|
Add this tag to every photo in the album. Saves you from having to
|
|
|
|
write the for-loop yourself.
|
|
|
|
|
|
|
|
nested_children:
|
|
|
|
If True, add the tag to photos contained in sub-albums.
|
|
|
|
Otherwise, only local photos.
|
|
|
|
'''
|
2018-02-17 06:25:56 +00:00
|
|
|
tag = self.photodb.get_tag(name=tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
if nested_children:
|
|
|
|
photos = self.walk_photos()
|
|
|
|
else:
|
2018-02-17 07:03:54 +00:00
|
|
|
photos = self.get_photos()
|
2017-11-17 00:46:39 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
for photo in photos:
|
|
|
|
photo.add_tag(tag, commit=False)
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add tag to all')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_children=False, commit=True):
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Deleting %s', self)
|
2016-12-17 04:02:08 +00:00
|
|
|
GroupableMixin.delete(self, delete_children=delete_children, commit=False)
|
2018-02-26 00:17:19 +00:00
|
|
|
self.photodb.sql_delete(table='album_associated_directories', pairs={'albumid': self.id})
|
2018-06-30 19:51:09 +00:00
|
|
|
self.photodb.sql_delete(table='album_photo_rel', pairs={'albumid': self.id})
|
|
|
|
self.photodb.sql_delete(table='albums', pairs={'id': self.id})
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-03-11 01:08:38 +00:00
|
|
|
@property
|
|
|
|
def display_name(self):
|
|
|
|
if self.title:
|
|
|
|
return self.title
|
|
|
|
else:
|
|
|
|
return self.id
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def edit(self, title=None, description=None, *, commit=True):
|
2017-03-10 23:01:12 +00:00
|
|
|
'''
|
|
|
|
Change the title or description. Leave None to keep current value.
|
|
|
|
'''
|
2018-02-17 04:19:18 +00:00
|
|
|
if title is None and description is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
if title is not None:
|
2018-03-23 06:39:11 +00:00
|
|
|
self.title = self.normalize_title(title)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
if description is not None:
|
2018-03-23 06:39:11 +00:00
|
|
|
self.description = self.normalize_description(description)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'title': self.title,
|
|
|
|
'description': self.description,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='albums', pairs=data, where_key='id')
|
2017-11-12 23:21:53 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - edit album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-02-17 21:33:27 +00:00
|
|
|
def get_associated_directories(self):
|
2018-03-27 03:07:42 +00:00
|
|
|
directory_rows = self.photodb.sql_select(
|
2018-02-17 21:33:27 +00:00
|
|
|
'SELECT directory FROM album_associated_directories WHERE albumid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2018-03-27 03:07:42 +00:00
|
|
|
directories = [x[0] for x in directory_rows]
|
2018-02-17 21:33:27 +00:00
|
|
|
directories = [pathclass.Path(x) for x in directories]
|
|
|
|
return directories
|
|
|
|
|
2018-02-17 07:03:54 +00:00
|
|
|
def get_photos(self):
|
|
|
|
photos = []
|
2018-03-25 23:32:17 +00:00
|
|
|
generator = self.photodb.sql_select(
|
2018-02-17 07:03:54 +00:00
|
|
|
'SELECT photoid FROM album_photo_rel WHERE albumid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2018-04-15 09:14:06 +00:00
|
|
|
photo_ids = [row[0] for row in generator]
|
|
|
|
photos = self.photodb.get_photos_by_id(photo_ids)
|
|
|
|
photos = sorted(photos, key=lambda x: x.basename.lower())
|
2018-02-17 07:03:54 +00:00
|
|
|
return photos
|
|
|
|
|
2018-04-15 09:41:24 +00:00
|
|
|
def has_any_photo(self, recurse=False):
|
|
|
|
row = self.photodb.sql_select_one(
|
2018-07-29 23:30:30 +00:00
|
|
|
'SELECT 1 FROM album_photo_rel WHERE albumid == ? LIMIT 1',
|
2018-04-15 09:41:24 +00:00
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
if row is not None:
|
|
|
|
return True
|
|
|
|
if recurse:
|
|
|
|
return self.has_any_subalbum_photo()
|
|
|
|
return False
|
|
|
|
|
|
|
|
def has_any_subalbum_photo(self):
|
2018-07-29 23:30:30 +00:00
|
|
|
'''
|
|
|
|
Return True if any descendent album has any photo, ignoring whether
|
|
|
|
this particular album itself has photos.
|
|
|
|
'''
|
2018-04-15 09:41:24 +00:00
|
|
|
return any(child.has_any_photo(recurse=True) for child in self.get_children())
|
|
|
|
|
2018-07-29 23:30:30 +00:00
|
|
|
def has_associated_directory(self, path):
|
|
|
|
path = pathclass.Path(path)
|
|
|
|
row = self.photodb.sql_select_one(
|
|
|
|
'SELECT 1 FROM album_associated_directories WHERE albumid == ? AND directory == ?',
|
|
|
|
[self.id, path.absolute_path]
|
|
|
|
)
|
|
|
|
return row is not None
|
2018-03-27 03:07:42 +00:00
|
|
|
|
2018-07-29 23:30:30 +00:00
|
|
|
def has_photo(self, photo):
|
|
|
|
row = self.photodb.sql_select_one(
|
2018-03-27 03:07:42 +00:00
|
|
|
'SELECT 1 FROM album_photo_rel WHERE albumid == ? AND photoid == ?',
|
2016-12-17 04:02:08 +00:00
|
|
|
[self.id, photo.id]
|
|
|
|
)
|
2018-07-29 23:30:30 +00:00
|
|
|
return row is not None
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-07-23 04:06:16 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
# GroupableMixin.remove_child already has @transaction.
|
|
|
|
def remove_child(self, *args, **kwargs):
|
|
|
|
return super().remove_child(*args, **kwargs)
|
|
|
|
|
2018-05-02 01:05:07 +00:00
|
|
|
def _remove_photo(self, photo):
|
2018-01-12 03:32:15 +00:00
|
|
|
self.photodb.log.debug('Removing photo %s from %s', photo, self)
|
2018-02-26 00:17:19 +00:00
|
|
|
pairs = {'albumid': self.id, 'photoid': photo.id}
|
|
|
|
self.photodb.sql_delete(table='album_photo_rel', pairs=pairs)
|
2018-05-02 01:05:07 +00:00
|
|
|
|
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def remove_photo(self, photo, *, commit=True):
|
|
|
|
self._remove_photo(photo)
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2017-02-05 02:30:02 +00:00
|
|
|
self.photodb.log.debug('Committing - remove photo from album')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-05-02 01:05:07 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def remove_photos(self, photos, *, commit=True):
|
|
|
|
existing_photos = set(self.get_photos())
|
|
|
|
photos = set(photos)
|
|
|
|
photos = photos.intersection(existing_photos)
|
|
|
|
|
|
|
|
for photo in photos:
|
|
|
|
self._remove_photo(photo)
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - remove photos from album')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-04-15 09:52:41 +00:00
|
|
|
def sum_bytes(self, recurse=True):
|
|
|
|
query = '''
|
|
|
|
SELECT SUM(bytes) FROM photos
|
|
|
|
WHERE photos.id IN (
|
|
|
|
SELECT photoid FROM album_photo_rel WHERE
|
2018-07-20 05:42:21 +00:00
|
|
|
albumid IN {albumids}
|
2018-04-15 09:52:41 +00:00
|
|
|
)
|
|
|
|
'''
|
2017-03-23 05:54:17 +00:00
|
|
|
if recurse:
|
2018-04-15 09:52:41 +00:00
|
|
|
albumids = [child.id for child in self.walk_children()]
|
2017-02-25 06:07:59 +00:00
|
|
|
else:
|
2018-04-15 09:52:41 +00:00
|
|
|
albumids = [self.id]
|
|
|
|
|
2019-01-02 02:08:47 +00:00
|
|
|
albumids = sqlhelpers.listify(albumids)
|
2018-07-20 05:42:21 +00:00
|
|
|
query = query.format(albumids=albumids)
|
|
|
|
total = self.photodb.sql_select_one(query)[0]
|
2018-04-15 09:52:41 +00:00
|
|
|
return total
|
2017-02-25 06:07:59 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
def sum_photos(self, recurse=True):
|
2018-11-13 06:15:59 +00:00
|
|
|
'''
|
|
|
|
If all you need is the number of photos in the album, this method is
|
|
|
|
preferable to len(album.get_photos()) because it performs the counting
|
|
|
|
in the database instead of creating the Photo objects.
|
|
|
|
'''
|
2018-07-20 05:42:21 +00:00
|
|
|
query = '''
|
|
|
|
SELECT COUNT(photoid)
|
|
|
|
FROM album_photo_rel
|
|
|
|
WHERE albumid IN {albumids}
|
|
|
|
'''
|
|
|
|
if recurse:
|
|
|
|
albumids = [child.id for child in self.walk_children()]
|
|
|
|
else:
|
|
|
|
albumids = [self.id]
|
2017-11-12 23:21:53 +00:00
|
|
|
|
2019-01-02 02:08:47 +00:00
|
|
|
albumids = sqlhelpers.listify(albumids)
|
2018-07-20 05:42:21 +00:00
|
|
|
query = query.format(albumids=albumids)
|
|
|
|
total = self.photodb.sql_select_one(query)[0]
|
|
|
|
return total
|
2017-11-12 23:21:53 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def walk_photos(self):
|
2018-02-17 07:03:54 +00:00
|
|
|
yield from self.get_photos()
|
2016-12-17 04:02:08 +00:00
|
|
|
children = self.walk_children()
|
|
|
|
# The first yield is itself
|
|
|
|
next(children)
|
|
|
|
for child in children:
|
|
|
|
yield from child.walk_photos()
|
|
|
|
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
class Bookmark(ObjectBase):
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2018-02-17 04:40:58 +00:00
|
|
|
db_row = dict(zip(constants.SQL_COLUMNS['bookmarks'], db_row))
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
2018-03-23 06:47:53 +00:00
|
|
|
self.title = self.normalize_title(db_row['title'])
|
|
|
|
self.url = self.normalize_url(db_row['url'])
|
2018-03-23 06:38:10 +00:00
|
|
|
self.author_id = self.normalize_author_id(db_row['author_id'])
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
2018-04-15 08:13:02 +00:00
|
|
|
return f'Bookmark:{self.id}'
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2018-03-23 06:47:53 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_title(title):
|
|
|
|
if title is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(title, str):
|
2018-07-19 01:36:36 +00:00
|
|
|
raise TypeError(f'Title must be string, not {type(title)}')
|
2018-03-23 06:47:53 +00:00
|
|
|
|
|
|
|
title = title.strip()
|
|
|
|
for whitespace in string.whitespace:
|
|
|
|
title = title.replace(whitespace, ' ')
|
|
|
|
|
|
|
|
return title
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def normalize_url(url):
|
|
|
|
if url is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(url, str):
|
2018-07-19 01:36:36 +00:00
|
|
|
raise TypeError(f'URL must be string, not {type(url)}')
|
2018-03-23 06:47:53 +00:00
|
|
|
|
|
|
|
url = url.strip()
|
|
|
|
|
|
|
|
if not url:
|
2018-07-19 01:36:36 +00:00
|
|
|
raise ValueError(f'Invalid URL "{url}"')
|
2018-03-23 06:47:53 +00:00
|
|
|
|
|
|
|
return url
|
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['bookmark'].remove(self.id)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('bookmark.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-02-05 02:30:02 +00:00
|
|
|
def delete(self, *, commit=True):
|
2018-02-26 00:17:19 +00:00
|
|
|
self.photodb.sql_delete(table='bookmarks', pairs={'id': self.id})
|
2018-07-20 05:42:21 +00:00
|
|
|
self._uncache()
|
2017-02-05 02:30:02 +00:00
|
|
|
if commit:
|
2017-03-17 04:44:49 +00:00
|
|
|
self.photodb.commit()
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('bookmark.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-02-05 02:30:02 +00:00
|
|
|
def edit(self, title=None, url=None, *, commit=True):
|
2017-11-17 00:46:39 +00:00
|
|
|
'''
|
|
|
|
Change the title or URL. Leave None to keep current.
|
|
|
|
'''
|
2017-02-05 02:30:02 +00:00
|
|
|
if title is None and url is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
if title is not None:
|
2018-03-23 06:47:53 +00:00
|
|
|
self.title = self.normalize_title(title)
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
if url is not None:
|
2018-03-23 06:47:53 +00:00
|
|
|
self.url = self.normalize_url(url)
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2018-02-17 04:19:18 +00:00
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'title': self.title,
|
|
|
|
'url': self.url,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='bookmarks', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2017-02-05 02:30:02 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - edit bookmark')
|
2017-03-17 04:44:49 +00:00
|
|
|
self.photodb.commit()
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
class Photo(ObjectBase):
|
|
|
|
'''
|
|
|
|
A PhotoDB entry containing information about an image file.
|
|
|
|
Photo objects cannot exist without a corresponding PhotoDB object, because
|
|
|
|
Photos are not the actual image data, just the database entry.
|
|
|
|
'''
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2018-02-17 04:40:58 +00:00
|
|
|
db_row = dict(zip(constants.SQL_COLUMNS['photos'], db_row))
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-02-17 23:19:36 +00:00
|
|
|
self.real_path = db_row['filepath']
|
|
|
|
self.real_path = helpers.remove_path_badchars(self.real_path, allowed=':\\/')
|
|
|
|
self.real_path = pathclass.Path(self.real_path)
|
2016-12-21 05:33:14 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.created = db_row['created']
|
2018-03-23 06:38:10 +00:00
|
|
|
self.author_id = self.normalize_author_id(db_row['author_id'])
|
2017-03-04 05:15:31 +00:00
|
|
|
self.basename = db_row['override_filename'] or self.real_path.basename
|
|
|
|
self.extension = db_row['extension']
|
|
|
|
self.tagged_at = db_row['tagged_at']
|
2016-12-21 05:33:14 +00:00
|
|
|
|
2016-12-25 02:34:34 +00:00
|
|
|
if self.extension == '':
|
|
|
|
self.dot_extension = ''
|
|
|
|
else:
|
|
|
|
self.dot_extension = '.' + self.extension
|
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
self.area = db_row['area']
|
|
|
|
self.bytes = db_row['bytes']
|
|
|
|
self.duration = db_row['duration']
|
|
|
|
self.width = db_row['width']
|
|
|
|
self.height = db_row['height']
|
|
|
|
self.ratio = db_row['ratio']
|
2018-03-13 09:50:54 +00:00
|
|
|
|
|
|
|
if db_row['thumbnail'] is not None:
|
|
|
|
self.thumbnail = self.photodb.thumbnail_directory.join(db_row['thumbnail'])
|
|
|
|
else:
|
|
|
|
self.thumbnail = None
|
|
|
|
|
2018-03-10 01:10:27 +00:00
|
|
|
self.searchhidden = db_row['searchhidden']
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-02-17 23:19:36 +00:00
|
|
|
self.mimetype = helpers.get_mimetype(self.real_path.basename)
|
2017-02-28 07:39:06 +00:00
|
|
|
if self.mimetype is None:
|
|
|
|
self.simple_mimetype = None
|
|
|
|
else:
|
|
|
|
self.simple_mimetype = self.mimetype.split('/')[0]
|
2016-12-24 03:49:51 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def __reinit__(self):
|
|
|
|
'''
|
|
|
|
Reload the row from the database and do __init__ with them.
|
|
|
|
'''
|
2018-03-27 03:07:42 +00:00
|
|
|
row = self.photodb.sql_select_one('SELECT * FROM photos WHERE id == ?', [self.id])
|
2016-12-17 04:02:08 +00:00
|
|
|
self.__init__(self.photodb, row)
|
|
|
|
|
|
|
|
def __repr__(self):
|
2018-04-15 08:13:02 +00:00
|
|
|
return f'Photo:{self.id}'
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['photo'].remove(self.id)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.add_remove_tag')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_tag(self, tag, *, commit=True):
|
2018-02-17 06:25:56 +00:00
|
|
|
tag = self.photodb.get_tag(name=tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-10 22:04:50 +00:00
|
|
|
existing = self.has_tag(tag, check_children=False)
|
|
|
|
if existing:
|
|
|
|
return existing
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-08 04:20:12 +00:00
|
|
|
# If the new tag is less specific than one we already have,
|
|
|
|
# keep our current one.
|
2016-12-17 04:02:08 +00:00
|
|
|
existing = self.has_tag(tag, check_children=True)
|
|
|
|
if existing:
|
2018-07-29 23:01:26 +00:00
|
|
|
self.photodb.log.debug('Preferring existing %s over %s', existing, tag)
|
2017-03-10 22:04:50 +00:00
|
|
|
return existing
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-03-08 04:20:12 +00:00
|
|
|
# If the new tag is more specific, remove our current one for it.
|
2016-12-17 04:02:08 +00:00
|
|
|
for parent in tag.walk_parents():
|
|
|
|
if self.has_tag(parent, check_children=False):
|
2018-07-29 23:01:26 +00:00
|
|
|
self.photodb.log.debug('Preferring new %s over %s', tag, parent)
|
2018-04-15 08:14:20 +00:00
|
|
|
self.remove_tag(parent, commit=False)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Applying %s to %s', tag, self)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'photoid': self.id,
|
|
|
|
'tagid': tag.id
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_insert(table='photo_tag_rel', data=data)
|
2018-02-17 04:19:18 +00:00
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'tagged_at': helpers.now(),
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add photo tag')
|
|
|
|
self.photodb.commit()
|
2017-03-10 22:04:50 +00:00
|
|
|
return tag
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
@property
|
|
|
|
def bitrate(self):
|
|
|
|
if self.duration and self.bytes is not None:
|
|
|
|
return (self.bytes / 128) / self.duration
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
2018-02-17 07:16:02 +00:00
|
|
|
@property
|
2016-12-17 04:02:08 +00:00
|
|
|
def bytestring(self):
|
2017-06-16 06:08:20 +00:00
|
|
|
if self.bytes is not None:
|
|
|
|
return bytestring.bytestring(self.bytes)
|
|
|
|
return '??? b'
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
# Photo.add_tag already has required_feature add_remove_tag
|
2018-02-26 02:55:46 +00:00
|
|
|
# Photo.add_tag already has @transaction.
|
2018-02-26 02:56:11 +00:00
|
|
|
def copy_tags(self, other_photo, *, commit=True):
|
2017-03-10 23:01:12 +00:00
|
|
|
'''
|
|
|
|
Take all of the tags owned by other_photo and apply them to this photo.
|
|
|
|
'''
|
2018-02-17 07:07:21 +00:00
|
|
|
for tag in other_photo.get_tags():
|
2018-02-26 02:56:11 +00:00
|
|
|
self.add_tag(tag, commit=False)
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - copy tags')
|
|
|
|
self.photodb.commit()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_file=False, commit=True):
|
|
|
|
'''
|
|
|
|
Delete the Photo and its relation to any tags and albums.
|
|
|
|
'''
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Deleting %s', self)
|
2018-02-26 00:17:19 +00:00
|
|
|
self.photodb.sql_delete(table='photo_tag_rel', pairs={'photoid': self.id})
|
|
|
|
self.photodb.sql_delete(table='album_photo_rel', pairs={'photoid': self.id})
|
2018-07-20 05:42:21 +00:00
|
|
|
self.photodb.sql_delete(table='photos', pairs={'id': self.id})
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
if delete_file:
|
|
|
|
path = self.real_path.absolute_path
|
|
|
|
if commit:
|
|
|
|
os.remove(path)
|
|
|
|
else:
|
|
|
|
queue_action = {'action': os.remove, 'args': [path]}
|
|
|
|
self.photodb.on_commit_queue.append(queue_action)
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete photo')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-03-10 23:07:34 +00:00
|
|
|
@property
|
2016-12-21 00:33:40 +00:00
|
|
|
def duration_string(self):
|
|
|
|
if self.duration is None:
|
|
|
|
return None
|
|
|
|
return helpers.seconds_to_hms(self.duration)
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
#@decorators.time_me
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.generate_thumbnail')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def generate_thumbnail(self, *, commit=True, **special):
|
|
|
|
'''
|
|
|
|
special:
|
2017-03-20 00:34:19 +00:00
|
|
|
For videos, you can provide a `timestamp` to take the thumbnail at.
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
hopeful_filepath = self.make_thumbnail_filepath()
|
|
|
|
return_filepath = None
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
if self.simple_mimetype == 'image':
|
2018-02-25 07:06:25 +00:00
|
|
|
self.photodb.log.debug('Thumbnailing %s', self.real_path.absolute_path)
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
2018-04-29 03:33:05 +00:00
|
|
|
image = helpers.generate_image_thumbnail(
|
|
|
|
self.real_path.absolute_path,
|
|
|
|
width=self.photodb.config['thumbnail_width'],
|
|
|
|
height=self.photodb.config['thumbnail_height'],
|
|
|
|
)
|
2016-12-17 04:02:08 +00:00
|
|
|
except (OSError, ValueError):
|
2018-04-29 03:33:05 +00:00
|
|
|
traceback.print_exc()
|
2016-12-17 04:02:08 +00:00
|
|
|
else:
|
2018-03-13 09:50:54 +00:00
|
|
|
image.save(hopeful_filepath.absolute_path, quality=50)
|
2016-12-17 04:02:08 +00:00
|
|
|
return_filepath = hopeful_filepath
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
elif self.simple_mimetype == 'video' and constants.ffmpeg:
|
2018-02-25 07:06:25 +00:00
|
|
|
self.photodb.log.debug('Thumbnailing %s', self.real_path.absolute_path)
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
2018-04-29 03:36:33 +00:00
|
|
|
success = helpers.generate_video_thumbnail(
|
|
|
|
self.real_path.absolute_path,
|
|
|
|
outfile=hopeful_filepath.absolute_path,
|
|
|
|
width=self.photodb.config['thumbnail_width'],
|
|
|
|
height=self.photodb.config['thumbnail_height'],
|
|
|
|
**special
|
|
|
|
)
|
2018-03-11 09:54:59 +00:00
|
|
|
except Exception:
|
2016-12-17 04:02:08 +00:00
|
|
|
traceback.print_exc()
|
|
|
|
else:
|
2018-04-29 03:36:33 +00:00
|
|
|
if success:
|
|
|
|
return_filepath = hopeful_filepath
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
if return_filepath != self.thumbnail:
|
2018-05-02 03:41:38 +00:00
|
|
|
if return_filepath is not None:
|
2018-11-04 21:30:34 +00:00
|
|
|
return_filepath = return_filepath.absolute_path
|
2018-02-17 04:19:18 +00:00
|
|
|
data = {
|
|
|
|
'id': self.id,
|
2018-05-02 03:41:38 +00:00
|
|
|
'thumbnail': return_filepath,
|
2018-02-17 04:19:18 +00:00
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
2016-12-17 04:02:08 +00:00
|
|
|
self.thumbnail = return_filepath
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - generate thumbnail')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
self.__reinit__()
|
|
|
|
return self.thumbnail
|
|
|
|
|
2018-02-17 07:08:44 +00:00
|
|
|
def get_containing_albums(self):
|
|
|
|
'''
|
|
|
|
Return the albums of which this photo is a member.
|
|
|
|
'''
|
2018-04-29 02:17:11 +00:00
|
|
|
album_ids = self.photodb.sql_select(
|
2018-03-27 03:07:42 +00:00
|
|
|
'SELECT albumid FROM album_photo_rel WHERE photoid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2018-04-29 02:17:11 +00:00
|
|
|
album_ids = [row[0] for row in album_ids]
|
|
|
|
albums = list(self.photodb.get_albums_by_id(album_ids))
|
2018-02-17 07:08:44 +00:00
|
|
|
return albums
|
|
|
|
|
2018-02-17 07:07:21 +00:00
|
|
|
def get_tags(self):
|
|
|
|
'''
|
|
|
|
Return the tags assigned to this Photo.
|
|
|
|
'''
|
2018-04-29 02:14:58 +00:00
|
|
|
tag_ids = self.photodb.sql_select(
|
2018-02-17 07:07:21 +00:00
|
|
|
'SELECT tagid FROM photo_tag_rel WHERE photoid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2018-04-29 02:14:58 +00:00
|
|
|
tag_ids = [row[0] for row in tag_ids]
|
|
|
|
tags = list(self.photodb.get_tags_by_id(tag_ids))
|
2018-02-17 07:07:21 +00:00
|
|
|
return tags
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def has_tag(self, tag, *, check_children=True):
|
|
|
|
'''
|
2017-03-08 04:20:12 +00:00
|
|
|
Return the Tag object if this photo contains that tag.
|
|
|
|
Otherwise return False.
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
check_children:
|
2017-03-08 04:20:12 +00:00
|
|
|
If True, children of the requested tag are accepted.
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
2018-02-17 06:25:56 +00:00
|
|
|
tag = self.photodb.get_tag(name=tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
if check_children:
|
2018-03-27 03:07:42 +00:00
|
|
|
tag_options = tag.walk_children()
|
2016-12-17 04:02:08 +00:00
|
|
|
else:
|
2018-03-27 03:07:42 +00:00
|
|
|
tag_options = [tag]
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-27 03:07:42 +00:00
|
|
|
tag_by_id = {t.id: t for t in tag_options}
|
2019-01-02 02:08:47 +00:00
|
|
|
tag_option_ids = sqlhelpers.listify(tag_by_id)
|
2018-03-27 03:07:42 +00:00
|
|
|
rel_row = self.photodb.sql_select_one(
|
2018-07-19 01:36:36 +00:00
|
|
|
f'SELECT tagid FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids}',
|
2018-03-27 03:07:42 +00:00
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
|
|
|
|
if rel_row is None:
|
|
|
|
return False
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-27 03:07:42 +00:00
|
|
|
return tag_by_id[rel_row[0]]
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def make_thumbnail_filepath(self):
|
2017-03-08 04:20:12 +00:00
|
|
|
'''
|
|
|
|
Create the filepath that should be the location of our thumbnail.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
chunked_id = helpers.chunk_sequence(self.id, 3)
|
2018-02-17 04:19:18 +00:00
|
|
|
(folder, basename) = (chunked_id[:-1], chunked_id[-1])
|
2016-12-17 04:02:08 +00:00
|
|
|
folder = os.sep.join(folder)
|
2016-12-25 01:13:45 +00:00
|
|
|
folder = self.photodb.thumbnail_directory.join(folder)
|
2016-12-17 04:02:08 +00:00
|
|
|
if folder:
|
2016-12-25 01:13:45 +00:00
|
|
|
os.makedirs(folder.absolute_path, exist_ok=True)
|
|
|
|
hopeful_filepath = folder.with_child(basename + '.jpg')
|
2016-12-17 04:02:08 +00:00
|
|
|
return hopeful_filepath
|
|
|
|
|
2018-09-23 21:54:56 +00:00
|
|
|
# Photo.rename_file already has @required_feature
|
|
|
|
# Photo.rename_file already has @transaction
|
|
|
|
def move_file(self, directory, commit=True):
|
|
|
|
directory = pathclass.Path(directory)
|
|
|
|
directory.assert_is_directory()
|
|
|
|
new_path = directory.with_child(self.real_path.basename).absolute_path
|
|
|
|
self.rename_file(new_path, move=True, commit=commit)
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
#@decorators.time_me
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.reload_metadata')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def reload_metadata(self, *, commit=True):
|
|
|
|
'''
|
|
|
|
Load the file's height, width, etc as appropriate for this type of file.
|
|
|
|
'''
|
2018-02-17 23:19:36 +00:00
|
|
|
self.bytes = self.real_path.size
|
2016-12-17 04:02:08 +00:00
|
|
|
self.width = None
|
|
|
|
self.height = None
|
|
|
|
self.area = None
|
|
|
|
self.ratio = None
|
|
|
|
self.duration = None
|
|
|
|
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Reloading metadata for %s', self)
|
2017-02-05 02:30:02 +00:00
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
if self.simple_mimetype == 'image':
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
2018-02-17 23:19:36 +00:00
|
|
|
image = PIL.Image.open(self.real_path.absolute_path)
|
2016-12-17 04:02:08 +00:00
|
|
|
except (OSError, ValueError):
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Failed to read image data for %s', self)
|
2016-12-17 04:02:08 +00:00
|
|
|
else:
|
|
|
|
(self.width, self.height) = image.size
|
|
|
|
image.close()
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
elif self.simple_mimetype == 'video' and constants.ffmpeg:
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
2018-02-17 23:19:36 +00:00
|
|
|
probe = constants.ffmpeg.probe(self.real_path.absolute_path)
|
2016-12-17 04:02:08 +00:00
|
|
|
if probe and probe.video:
|
|
|
|
self.duration = probe.format.duration or probe.video.duration
|
|
|
|
self.width = probe.video.video_width
|
|
|
|
self.height = probe.video.video_height
|
2018-02-17 05:28:36 +00:00
|
|
|
except Exception:
|
2016-12-17 04:02:08 +00:00
|
|
|
traceback.print_exc()
|
|
|
|
|
2017-02-28 07:39:06 +00:00
|
|
|
elif self.simple_mimetype == 'audio' and constants.ffmpeg:
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
2018-02-17 23:19:36 +00:00
|
|
|
probe = constants.ffmpeg.probe(self.real_path.absolute_path)
|
2016-12-17 04:02:08 +00:00
|
|
|
if probe and probe.audio:
|
|
|
|
self.duration = probe.audio.duration
|
2018-02-17 05:28:36 +00:00
|
|
|
except Exception:
|
2016-12-17 04:02:08 +00:00
|
|
|
traceback.print_exc()
|
|
|
|
|
|
|
|
if self.width and self.height:
|
|
|
|
self.area = self.width * self.height
|
|
|
|
self.ratio = round(self.width / self.height, 2)
|
|
|
|
|
2018-02-17 04:19:18 +00:00
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'width': self.width,
|
|
|
|
'height': self.height,
|
|
|
|
'area': self.area,
|
|
|
|
'ratio': self.ratio,
|
|
|
|
'duration': self.duration,
|
|
|
|
'bytes': self.bytes,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - reload metadata')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2017-03-23 06:18:09 +00:00
|
|
|
def relocate(self, new_filepath, *, allow_duplicates=False, commit=True):
|
|
|
|
'''
|
|
|
|
Point the Photo object to a different filepath.
|
|
|
|
|
|
|
|
DOES NOT MOVE THE FILE, only acknowledges a move that was performed
|
|
|
|
outside of the system.
|
|
|
|
To rename or move the file, use `rename_file`.
|
|
|
|
|
|
|
|
allow_duplicates:
|
|
|
|
Allow even if there is another Photo for that path.
|
|
|
|
'''
|
|
|
|
new_filepath = pathclass.Path(new_filepath)
|
|
|
|
if not new_filepath.is_file:
|
|
|
|
raise FileNotFoundError(new_filepath.absolute_path)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2017-03-23 06:18:09 +00:00
|
|
|
if not allow_duplicates:
|
2018-05-07 03:57:05 +00:00
|
|
|
self.photodb.assert_no_such_photo_by_path(filepath=new_filepath)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'filepath': new_filepath.absolute_path,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2017-03-23 06:18:09 +00:00
|
|
|
if commit:
|
2017-11-27 23:56:16 +00:00
|
|
|
self.photodb.log.debug('Committing - relocate photo')
|
2017-03-23 06:18:09 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.add_remove_tag')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def remove_tag(self, tag, *, commit=True):
|
2018-02-17 06:25:56 +00:00
|
|
|
tag = self.photodb.get_tag(name=tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Removing %s from %s', tag, self)
|
2016-12-17 04:02:08 +00:00
|
|
|
tags = list(tag.walk_children())
|
2017-01-30 01:47:59 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
for tag in tags:
|
2018-02-26 00:17:19 +00:00
|
|
|
pairs = {'photoid': self.id, 'tagid': tag.id}
|
|
|
|
self.photodb.sql_delete(table='photo_tag_rel', pairs=pairs)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'tagged_at': helpers.now(),
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - remove photo tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def rename_file(self, new_filename, *, move=False, commit=True):
|
|
|
|
'''
|
|
|
|
Rename the file on the disk as well as in the database.
|
2017-03-08 04:20:12 +00:00
|
|
|
|
|
|
|
move:
|
|
|
|
If True, allow the file to be moved into another directory.
|
|
|
|
Otherwise, the rename must be local.
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
old_path = self.real_path
|
|
|
|
old_path.correct_case()
|
|
|
|
|
2017-12-08 05:15:10 +00:00
|
|
|
new_filename = helpers.remove_path_badchars(new_filename, allowed=':\\/')
|
2016-12-17 04:02:08 +00:00
|
|
|
if os.path.dirname(new_filename) == '':
|
|
|
|
new_path = old_path.parent.with_child(new_filename)
|
|
|
|
else:
|
|
|
|
new_path = pathclass.Path(new_filename)
|
2017-02-25 06:07:59 +00:00
|
|
|
#new_path.correct_case()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.photodb.log.debug(old_path)
|
|
|
|
self.photodb.log.debug(new_path)
|
|
|
|
if (new_path.parent != old_path.parent) and not move:
|
|
|
|
raise ValueError('Cannot move the file without param move=True')
|
|
|
|
|
|
|
|
if new_path.absolute_path == old_path.absolute_path:
|
|
|
|
raise ValueError('The new and old names are the same')
|
|
|
|
|
|
|
|
os.makedirs(new_path.parent.absolute_path, exist_ok=True)
|
|
|
|
|
2017-02-26 06:47:20 +00:00
|
|
|
if new_path.normcase != old_path.normcase:
|
|
|
|
# It's possible on case-insensitive systems to have the paths point
|
|
|
|
# to the same place while being differently cased, thus we couldn't
|
|
|
|
# make the intermediate link.
|
|
|
|
# Instead, we will do a simple rename in just a moment.
|
2016-12-17 04:02:08 +00:00
|
|
|
try:
|
|
|
|
os.link(old_path.absolute_path, new_path.absolute_path)
|
|
|
|
except OSError:
|
|
|
|
spinal.copy_file(old_path, new_path)
|
|
|
|
|
2018-02-17 22:02:11 +00:00
|
|
|
data = {
|
|
|
|
'filepath': (old_path.absolute_path, new_path.absolute_path),
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='filepath')
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-02-26 06:47:20 +00:00
|
|
|
if new_path.normcase == old_path.normcase:
|
|
|
|
# If they are equivalent but differently cased, just rename.
|
|
|
|
action = os.rename
|
|
|
|
args = [old_path.absolute_path, new_path.absolute_path]
|
|
|
|
else:
|
|
|
|
# Delete the original, leaving only the new copy / hardlink.
|
|
|
|
action = os.remove
|
|
|
|
args = [old_path.absolute_path]
|
2017-03-23 07:04:44 +00:00
|
|
|
|
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
2017-02-26 06:47:20 +00:00
|
|
|
action(*args)
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.log.debug('Committing - rename file')
|
|
|
|
self.photodb.commit()
|
|
|
|
else:
|
2017-03-10 13:18:49 +00:00
|
|
|
queue_action = {'action': action, 'args': args}
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb.on_commit_queue.append(queue_action)
|
|
|
|
|
|
|
|
self.__reinit__()
|
|
|
|
|
2018-03-10 01:10:27 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def set_searchhidden(self, searchhidden, *, commit=True):
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'searchhidden': bool(searchhidden),
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
|
|
|
|
|
|
|
self.searchhidden = searchhidden
|
|
|
|
|
|
|
|
if commit:
|
2018-04-15 20:49:10 +00:00
|
|
|
self.photodb.log.debug('Committing - set searchhidden')
|
2018-03-10 01:10:27 +00:00
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-02-26 02:55:46 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
|
|
|
@decorators.transaction
|
2018-02-17 23:07:26 +00:00
|
|
|
def set_override_filename(self, new_filename, *, commit=True):
|
2018-02-17 23:47:26 +00:00
|
|
|
if new_filename is not None:
|
|
|
|
cleaned = helpers.remove_path_badchars(new_filename)
|
|
|
|
cleaned = cleaned.strip()
|
|
|
|
if not cleaned:
|
2018-07-19 01:36:36 +00:00
|
|
|
raise ValueError(f'"{new_filename}" is not valid.')
|
2018-02-17 23:47:26 +00:00
|
|
|
new_filename = cleaned
|
2018-02-17 23:07:26 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'override_filename': new_filename,
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - set override filename')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-02-17 23:47:26 +00:00
|
|
|
self.__reinit__()
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
class Tag(ObjectBase, GroupableMixin):
|
|
|
|
'''
|
|
|
|
A Tag, which can be applied to Photos for organization.
|
|
|
|
'''
|
2017-03-04 09:13:22 +00:00
|
|
|
group_table = 'tag_group_rel'
|
2018-02-17 04:40:58 +00:00
|
|
|
group_sql_index = constants.SQL_INDEX[group_table]
|
2017-03-04 09:13:22 +00:00
|
|
|
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2018-02-17 04:40:58 +00:00
|
|
|
db_row = dict(zip(constants.SQL_COLUMNS['tags'], db_row))
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
2018-07-20 05:42:21 +00:00
|
|
|
|
|
|
|
# Do not pass the name through the normalizer. It may be grandfathered
|
|
|
|
# from previous character / length rules.
|
2017-03-04 05:15:31 +00:00
|
|
|
self.name = db_row['name']
|
2018-03-23 07:20:07 +00:00
|
|
|
self.description = self.normalize_description(db_row['description'])
|
2018-03-23 06:38:10 +00:00
|
|
|
self.author_id = self.normalize_author_id(db_row['author_id'])
|
2018-03-18 22:28:26 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
self.group_getter = self.photodb.get_tag
|
2018-04-15 09:14:06 +00:00
|
|
|
self.group_getter_many = self.photodb.get_tags_by_id
|
2018-05-07 04:04:19 +00:00
|
|
|
self._cached_synonyms = None
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
2018-07-19 01:36:36 +00:00
|
|
|
return f'Tag:{self.id}:{self.name}'
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def __str__(self):
|
2018-07-19 01:36:36 +00:00
|
|
|
return f'Tag:{self.name}'
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-23 07:20:07 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_description(description):
|
|
|
|
if description is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(description, str):
|
2018-07-19 01:36:36 +00:00
|
|
|
raise TypeError(f'Description must be string, not {type(description)}')
|
2018-03-23 07:20:07 +00:00
|
|
|
|
|
|
|
description = description.strip()
|
|
|
|
|
|
|
|
return description
|
|
|
|
|
2018-04-20 22:28:27 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_name(name, valid_chars=None, min_length=None, max_length=None):
|
|
|
|
original_name = name
|
|
|
|
if valid_chars is None:
|
2018-07-20 05:42:21 +00:00
|
|
|
valid_chars = constants.DEFAULT_CONFIGURATION['tag']['valid_chars']
|
2018-04-20 22:28:27 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
name = name.lower().strip()
|
|
|
|
name = name.strip('.+')
|
|
|
|
name = name.split('+')[0].split('.')[-1]
|
2018-04-20 22:28:27 +00:00
|
|
|
name = name.replace('-', '_')
|
|
|
|
name = name.replace(' ', '_')
|
2018-07-20 05:42:21 +00:00
|
|
|
name = ''.join(c for c in name if c in valid_chars)
|
2018-04-20 22:28:27 +00:00
|
|
|
|
|
|
|
if min_length is not None and len(name) < min_length:
|
|
|
|
raise exceptions.TagTooShort(original_name)
|
|
|
|
|
|
|
|
if max_length is not None and len(name) > max_length:
|
|
|
|
raise exceptions.TagTooLong(name)
|
|
|
|
|
|
|
|
return name
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
def _uncache(self):
|
|
|
|
self.photodb.caches['tag'].remove(self.id)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2018-02-26 02:55:46 +00:00
|
|
|
# GroupableMixin.add_child already has @transaction.
|
2017-11-12 06:41:26 +00:00
|
|
|
def add_child(self, *args, **kwargs):
|
|
|
|
return super().add_child(*args, **kwargs)
|
2017-06-15 06:15:47 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
|
|
|
# GroupableMixin.add_children already has @transaction.
|
|
|
|
def add_children(self, *args, **kwargs):
|
|
|
|
return super().add_children(*args, **kwargs)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def add_synonym(self, synname, *, commit=True):
|
|
|
|
synname = self.photodb.normalize_tagname(synname)
|
|
|
|
|
|
|
|
if synname == self.name:
|
2018-09-23 21:57:25 +00:00
|
|
|
raise exceptions.CantSynonymSelf(self)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-05-07 03:57:05 +00:00
|
|
|
self.photodb.assert_no_such_tag(name=synname)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
self.photodb.log.debug('New synonym %s of %s', synname, self.name)
|
2018-01-12 03:32:15 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb._cached_frozen_children = None
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'name': synname,
|
|
|
|
'mastername': self.name,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_insert(table='tag_synonyms', data=data)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-05-07 04:04:19 +00:00
|
|
|
if self._cached_synonyms is not None:
|
|
|
|
self._cached_synonyms.add(synname)
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - add synonym')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-04-21 01:28:11 +00:00
|
|
|
return synname
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def convert_to_synonym(self, mastertag, *, commit=True):
|
|
|
|
'''
|
2017-03-20 00:34:19 +00:00
|
|
|
Convert this tag into a synonym for a different tag.
|
|
|
|
All photos which possess the current tag will have it replaced with the
|
|
|
|
new master tag.
|
2016-12-17 04:02:08 +00:00
|
|
|
All synonyms of the old tag will point to the new tag.
|
|
|
|
|
|
|
|
Good for when two tags need to be merged under a single name.
|
|
|
|
'''
|
2018-02-17 06:25:56 +00:00
|
|
|
mastertag = self.photodb.get_tag(name=mastertag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.photodb._cached_frozen_children = None
|
2018-02-17 22:02:11 +00:00
|
|
|
|
2018-03-27 03:07:42 +00:00
|
|
|
# Migrate the old tag's synonyms to the new one
|
|
|
|
# UPDATE is safe for this operation because there is no chance of duplicates.
|
2018-05-07 04:04:19 +00:00
|
|
|
my_synonyms = self.get_synonyms()
|
2018-02-17 22:02:11 +00:00
|
|
|
data = {
|
|
|
|
'mastername': (self.name, mastertag.name),
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='tag_synonyms', pairs=data, where_key='mastername')
|
2018-05-07 04:04:19 +00:00
|
|
|
if mastertag._cached_synonyms is not None:
|
|
|
|
mastertag._cached_synonyms.update(my_synonyms)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2018-03-27 03:07:42 +00:00
|
|
|
# Because these were two separate tags, perhaps in separate trees, it
|
|
|
|
# is possible for a photo to have both at the moment.
|
|
|
|
#
|
|
|
|
# If they already have both, the deletion of the syn rel will happen
|
|
|
|
# when the syn tag is deleted.
|
|
|
|
# If they only have the syn, we will UPDATE it to the master.
|
|
|
|
# If they only have the master, nothing needs to happen.
|
|
|
|
|
|
|
|
# Find photos that have the old tag and DON'T already have the new one.
|
|
|
|
query = '''
|
|
|
|
SELECT photoid FROM photo_tag_rel p1
|
|
|
|
WHERE tagid == ?
|
|
|
|
AND NOT EXISTS (
|
|
|
|
SELECT 1 FROM photo_tag_rel p2
|
|
|
|
WHERE p1.photoid == p2.photoid
|
|
|
|
AND tagid == ?
|
|
|
|
)
|
|
|
|
'''
|
|
|
|
bindings = [self.id, mastertag.id]
|
|
|
|
replace_photoids = [row[0] for row in self.photodb.sql_execute(query, bindings)]
|
|
|
|
|
|
|
|
# For those photos that only had the syn, simply replace with master.
|
|
|
|
if replace_photoids:
|
2018-07-19 01:36:36 +00:00
|
|
|
query = f'''
|
2018-03-27 03:07:42 +00:00
|
|
|
UPDATE photo_tag_rel
|
|
|
|
SET tagid = ?
|
|
|
|
WHERE tagid == ?
|
2019-01-02 02:08:47 +00:00
|
|
|
AND photoid IN {sqlhelpers.listify(replace_photoids)}
|
2018-07-19 01:36:36 +00:00
|
|
|
'''
|
2018-03-27 03:07:42 +00:00
|
|
|
bindings = [mastertag.id, self.id]
|
|
|
|
self.photodb.sql_execute(query, bindings)
|
|
|
|
|
|
|
|
# For photos that have the old tag and DO already have the new one,
|
|
|
|
# don't worry because the old rels will be deleted when the tag is
|
|
|
|
# deleted.
|
|
|
|
self.delete(commit=False)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
# Enjoy your new life as a monk.
|
|
|
|
mastertag.add_synonym(self.name, commit=False)
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - convert to synonym')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def delete(self, *, delete_children=False, commit=True):
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Deleting %s', self)
|
2016-12-17 04:02:08 +00:00
|
|
|
self.photodb._cached_frozen_children = None
|
|
|
|
GroupableMixin.delete(self, delete_children=delete_children, commit=False)
|
2018-02-26 00:17:19 +00:00
|
|
|
self.photodb.sql_delete(table='photo_tag_rel', pairs={'tagid': self.id})
|
|
|
|
self.photodb.sql_delete(table='tag_synonyms', pairs={'mastername': self.name})
|
2018-05-04 01:59:50 +00:00
|
|
|
self.photodb.sql_delete(table='tags', pairs={'id': self.id})
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - delete tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-13 00:31:17 +00:00
|
|
|
@decorators.transaction
|
|
|
|
def edit(self, description=None, *, commit=True):
|
|
|
|
'''
|
|
|
|
Change the description. Leave None to keep current value.
|
|
|
|
'''
|
|
|
|
if description is None:
|
2018-02-17 04:19:18 +00:00
|
|
|
return
|
|
|
|
|
2018-03-23 07:20:07 +00:00
|
|
|
self.description = self.normalize_description(description)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'description': self.description
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='tags', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2017-05-13 00:31:17 +00:00
|
|
|
self._uncache()
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - edit tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2018-02-17 07:01:07 +00:00
|
|
|
def get_synonyms(self):
|
2018-05-07 04:04:19 +00:00
|
|
|
if self._cached_synonyms is not None:
|
|
|
|
return self._cached_synonyms.copy()
|
|
|
|
|
2018-03-27 03:07:42 +00:00
|
|
|
syn_rows = self.photodb.sql_select(
|
|
|
|
'SELECT name FROM tag_synonyms WHERE mastername == ?',
|
|
|
|
[self.name]
|
|
|
|
)
|
2018-05-07 04:04:19 +00:00
|
|
|
synonyms = set(row[0] for row in syn_rows)
|
|
|
|
self._cached_synonyms = synonyms.copy()
|
|
|
|
return synonyms
|
2018-02-17 07:01:07 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2018-02-26 02:55:46 +00:00
|
|
|
# GroupableMixin.leave_group already has @transaction.
|
2017-06-15 06:15:47 +00:00
|
|
|
def leave_group(self, *args, **kwargs):
|
|
|
|
return super().leave_group(*args, **kwargs)
|
|
|
|
|
2018-07-23 04:06:16 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
|
|
|
# GroupableMixin.remove_child already has @transaction.
|
|
|
|
def remove_child(self, *args, **kwargs):
|
|
|
|
return super().remove_child(*args, **kwargs)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def remove_synonym(self, synname, *, commit=True):
|
|
|
|
'''
|
|
|
|
Delete a synonym.
|
|
|
|
This will have no effect on photos or other synonyms because
|
|
|
|
they always resolve to the master tag before application.
|
|
|
|
'''
|
|
|
|
synname = self.photodb.normalize_tagname(synname)
|
2017-02-26 08:33:26 +00:00
|
|
|
if synname == self.name:
|
|
|
|
raise exceptions.NoSuchSynonym(synname)
|
|
|
|
|
2018-03-27 03:07:42 +00:00
|
|
|
syn_exists = self.photodb.sql_select_one(
|
|
|
|
'SELECT 1 FROM tag_synonyms WHERE mastername == ? AND name == ?',
|
2017-03-08 04:20:12 +00:00
|
|
|
[self.name, synname]
|
|
|
|
)
|
2018-03-27 03:07:42 +00:00
|
|
|
|
|
|
|
if syn_exists is None:
|
2016-12-17 04:02:08 +00:00
|
|
|
raise exceptions.NoSuchSynonym(synname)
|
|
|
|
|
|
|
|
self.photodb._cached_frozen_children = None
|
2018-02-26 00:17:19 +00:00
|
|
|
self.photodb.sql_delete(table='tag_synonyms', pairs={'name': synname})
|
2018-05-07 04:04:19 +00:00
|
|
|
if self._cached_synonyms is not None:
|
|
|
|
self._cached_synonyms.remove(synname)
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - remove synonym')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2016-12-17 04:02:08 +00:00
|
|
|
def rename(self, new_name, *, apply_to_synonyms=True, commit=True):
|
|
|
|
'''
|
|
|
|
Rename the tag. Does not affect its relation to Photos or tag groups.
|
|
|
|
'''
|
|
|
|
new_name = self.photodb.normalize_tagname(new_name)
|
2017-05-13 00:31:17 +00:00
|
|
|
old_name = self.name
|
|
|
|
if new_name == old_name:
|
2016-12-17 04:02:08 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
2018-03-11 09:54:59 +00:00
|
|
|
self.photodb.get_tag(name=new_name)
|
2016-12-17 04:02:08 +00:00
|
|
|
except exceptions.NoSuchTag:
|
|
|
|
pass
|
|
|
|
else:
|
2017-11-24 05:58:38 +00:00
|
|
|
raise exceptions.TagExists(new_name)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.photodb._cached_frozen_children = None
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'name': new_name,
|
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='tags', pairs=data, where_key='id')
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
if apply_to_synonyms:
|
2018-02-17 22:02:11 +00:00
|
|
|
data = {
|
|
|
|
'mastername': (old_name, new_name),
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='tag_synonyms', pairs=data, where_key='mastername')
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.name = new_name
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - rename tag')
|
|
|
|
self.photodb.commit()
|
|
|
|
|
|
|
|
|
|
|
|
class User(ObjectBase):
|
|
|
|
'''
|
|
|
|
A dear friend of ours.
|
|
|
|
'''
|
2017-03-04 05:15:31 +00:00
|
|
|
def __init__(self, photodb, db_row):
|
2017-04-23 04:16:30 +00:00
|
|
|
super().__init__(photodb)
|
2017-03-04 05:15:31 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2018-02-17 04:40:58 +00:00
|
|
|
db_row = dict(zip(constants.SQL_COLUMNS['users'], db_row))
|
2017-03-04 05:15:31 +00:00
|
|
|
self.id = db_row['id']
|
|
|
|
self.username = db_row['username']
|
|
|
|
self.created = db_row['created']
|
2018-02-17 02:40:57 +00:00
|
|
|
self.password_hash = db_row['password']
|
2018-07-20 05:42:21 +00:00
|
|
|
# Do not enforce maxlen here, they may be grandfathered in.
|
2018-04-15 21:23:24 +00:00
|
|
|
self._display_name = self.normalize_display_name(db_row['display_name'])
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
2018-07-19 01:36:36 +00:00
|
|
|
return f'User:{self.id}:{self.username}'
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
def __str__(self):
|
2018-07-19 01:36:36 +00:00
|
|
|
return f'User:{self.username}'
|
2016-12-25 01:13:45 +00:00
|
|
|
|
2018-04-15 21:23:24 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_display_name(display_name, max_length=None):
|
|
|
|
if display_name is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
if not isinstance(display_name, str):
|
2018-07-19 01:36:36 +00:00
|
|
|
raise TypeError(f'Display name must be string, not {type(display_name)}.')
|
2018-04-15 21:23:24 +00:00
|
|
|
|
|
|
|
display_name = display_name.strip()
|
|
|
|
|
|
|
|
if display_name == '':
|
|
|
|
return None
|
|
|
|
|
|
|
|
if max_length is not None and len(display_name) > max_length:
|
|
|
|
raise exceptions.DisplayNameTooLong(display_name=display_name, max_length=max_length)
|
|
|
|
|
|
|
|
return display_name
|
|
|
|
|
|
|
|
@property
|
|
|
|
def display_name(self):
|
|
|
|
if self._display_name is None:
|
|
|
|
return self.username
|
|
|
|
else:
|
|
|
|
return self._display_name
|
|
|
|
|
|
|
|
@decorators.required_feature('user.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def set_display_name(self, display_name, *, commit=True):
|
|
|
|
display_name = self.normalize_display_name(
|
|
|
|
display_name,
|
|
|
|
max_length=self.photodb.config['user']['max_display_name_length'],
|
|
|
|
)
|
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'display_name': display_name,
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='users', pairs=data, where_key='id')
|
|
|
|
|
|
|
|
self._display_name = display_name
|
|
|
|
|
|
|
|
if commit:
|
|
|
|
self.photodb.log.debug('Committing - set display name')
|
|
|
|
self.photodb.commit()
|
2017-02-25 06:07:59 +00:00
|
|
|
|
2018-07-19 01:36:36 +00:00
|
|
|
|
2016-12-25 01:13:45 +00:00
|
|
|
class WarningBag:
|
|
|
|
def __init__(self):
|
|
|
|
self.warnings = set()
|
|
|
|
|
|
|
|
def add(self, warning):
|
|
|
|
self.warnings.add(warning)
|