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.
|
|
|
|
'''
|
2020-09-20 19:04:49 +00:00
|
|
|
import abc
|
2016-12-17 04:02:08 +00:00
|
|
|
import os
|
|
|
|
import PIL.Image
|
2020-02-27 22:18:46 +00:00
|
|
|
import send2trash
|
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
|
2020-09-12 08:19:03 +00:00
|
|
|
from voussoirkit import sentinel
|
2018-11-05 03:27:20 +00:00
|
|
|
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
|
|
|
|
2020-09-12 08:19:03 +00:00
|
|
|
BAIL = sentinel.Sentinel('BAIL')
|
|
|
|
|
2019-04-27 01:52:56 +00:00
|
|
|
def normalize_db_row(db_row, table):
|
2020-09-19 10:52:42 +00:00
|
|
|
if isinstance(db_row, dict):
|
|
|
|
return db_row
|
|
|
|
|
2019-04-27 01:52:56 +00:00
|
|
|
if isinstance(db_row, (list, tuple)):
|
2020-09-19 10:52:42 +00:00
|
|
|
return dict(zip(constants.SQL_COLUMNS[table], db_row))
|
|
|
|
|
|
|
|
raise TypeError(f'db_row should be {dict}, {list}, or {tuple}, not {type(db_row)}.')
|
2019-04-27 01:52:56 +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
|
2020-09-20 18:16:07 +00:00
|
|
|
self.deleted = False
|
2017-04-23 04:16:30 +00:00
|
|
|
|
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):
|
2020-09-19 10:51:55 +00:00
|
|
|
raise TypeError(f'Author ID must be {str}, 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
|
|
|
|
|
2020-09-19 10:51:55 +00:00
|
|
|
if not all(c in constants.USER_ID_CHARACTERS for c in author_id):
|
|
|
|
raise ValueError(f'Author ID must consist only of {constants.USER_ID_CHARACTERS}.')
|
|
|
|
|
2018-03-23 06:38:10 +00:00
|
|
|
return author_id
|
|
|
|
|
2020-09-20 18:31:51 +00:00
|
|
|
def assert_not_deleted(self):
|
|
|
|
if self.deleted:
|
|
|
|
raise exceptions.DeletedObject(self)
|
|
|
|
|
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)
|
|
|
|
|
2020-09-20 19:04:49 +00:00
|
|
|
class GroupableMixin(metaclass=abc.ABCMeta):
|
2018-04-15 09:14:06 +00:00
|
|
|
group_getter_many = None
|
2017-03-04 09:13:22 +00:00
|
|
|
group_table = None
|
|
|
|
|
2020-09-20 19:04:49 +00:00
|
|
|
def __lift_children(self):
|
2018-05-04 01:32:44 +00:00
|
|
|
'''
|
2018-07-20 05:42:21 +00:00
|
|
|
If this object has parents, the parents adopt all of its children.
|
2020-09-12 07:53:12 +00:00
|
|
|
Otherwise, this object is at the root level, so the parental
|
|
|
|
relationship is simply deleted and the children become root level.
|
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
|
|
|
|
2020-09-20 19:04:49 +00:00
|
|
|
def __add_child(self, member):
|
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):
|
2020-09-12 08:19:03 +00:00
|
|
|
return BAIL
|
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
|
|
|
|
2020-09-20 19:04:49 +00:00
|
|
|
@abc.abstractmethod
|
2020-09-12 08:00:39 +00:00
|
|
|
def add_child(self, member):
|
2020-09-20 19:04:49 +00:00
|
|
|
return self.__add_child(member)
|
2020-09-12 08:00:39 +00:00
|
|
|
|
2020-09-20 19:04:49 +00:00
|
|
|
@abc.abstractmethod
|
2020-02-20 07:56:09 +00:00
|
|
|
def add_children(self, members):
|
2020-09-12 20:14:40 +00:00
|
|
|
bail = True
|
|
|
|
for member in members:
|
2020-09-28 02:49:27 +00:00
|
|
|
bail = (self.__add_child(member) is BAIL) and bail
|
2020-09-12 20:14:40 +00:00
|
|
|
if bail:
|
2020-09-12 08:19:03 +00:00
|
|
|
return BAIL
|
2018-07-20 05:42:21 +00:00
|
|
|
|
|
|
|
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.')
|
|
|
|
|
2020-09-20 19:04:49 +00:00
|
|
|
@abc.abstractmethod
|
2020-02-20 07:56:09 +00:00
|
|
|
def delete(self, *, delete_children=False):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
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.
|
|
|
|
'''
|
|
|
|
if delete_children:
|
2018-02-17 07:00:43 +00:00
|
|
|
for child in self.get_children():
|
2020-02-20 06:20:21 +00:00
|
|
|
child.delete(delete_children=True)
|
2016-12-17 04:02:08 +00:00
|
|
|
else:
|
2020-09-20 19:04:49 +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()
|
2020-09-20 18:16:07 +00:00
|
|
|
self.deleted = True
|
2016-12-17 04:02:08 +00:00
|
|
|
|
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]
|
|
|
|
)
|
2020-09-20 20:41:22 +00:00
|
|
|
child_ids = (child_id for (child_id,) in child_rows)
|
2020-09-20 20:16:52 +00:00
|
|
|
children = set(self.group_getter_many(child_ids))
|
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])
|
2020-09-20 20:41:22 +00:00
|
|
|
parent_ids = (parent_id for (parent_id,) in parent_rows)
|
2020-09-15 01:34:14 +00:00
|
|
|
parents = set(self.group_getter_many(parent_ids))
|
2018-07-20 05:42:21 +00:00
|
|
|
return parents
|
|
|
|
|
2020-09-27 20:01:19 +00:00
|
|
|
def has_ancestor(self, ancestor):
|
|
|
|
return ancestor in self.walk_parents()
|
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
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
|
|
|
|
2020-09-27 20:01:19 +00:00
|
|
|
def has_descendant(self, descendant):
|
|
|
|
return self in descendant.walk_parents()
|
|
|
|
|
|
|
|
def has_parent(self, parent):
|
|
|
|
self.assert_same_type(parent)
|
|
|
|
query = f'SELECT 1 FROM {self.group_table} WHERE parentid == ? AND memberid == ?'
|
|
|
|
row = self.photodb.sql_select_one(query, [parent.id, self.id])
|
|
|
|
return row is not None
|
|
|
|
|
2020-09-28 05:24:33 +00:00
|
|
|
def __remove_child(self, member):
|
2018-07-20 05:42:21 +00:00
|
|
|
if not self.has_child(member):
|
2020-09-12 08:19:03 +00:00
|
|
|
return BAIL
|
2018-05-04 01:18:13 +00:00
|
|
|
|
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)
|
2020-09-28 05:24:33 +00:00
|
|
|
@abc.abstractmethod
|
|
|
|
def remove_child(self, member):
|
|
|
|
return self.__remove_child(member)
|
|
|
|
|
|
|
|
@abc.abstractmethod
|
|
|
|
def remove_children(self, members):
|
|
|
|
bail = True
|
|
|
|
for member in members:
|
|
|
|
bail = (self.__remove_child(member) is BAIL) and bail
|
|
|
|
if bail:
|
|
|
|
return BAIL
|
2018-07-20 05:42:21 +00:00
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
def walk_children(self):
|
2020-03-20 02:03:47 +00:00
|
|
|
'''
|
|
|
|
Yield self and all descendants.
|
|
|
|
'''
|
2016-12-17 04:02:08 +00:00
|
|
|
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
|
|
|
'''
|
2020-03-20 02:03:47 +00:00
|
|
|
Yield all ancestors, but not self, in no particular order.
|
2018-07-20 05:42:21 +00:00
|
|
|
'''
|
|
|
|
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):
|
2019-04-27 01:52:56 +00:00
|
|
|
table = 'albums'
|
2017-03-04 09:13:22 +00:00
|
|
|
group_table = 'album_group_rel'
|
|
|
|
|
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)
|
2019-04-27 01:52:56 +00:00
|
|
|
db_row = normalize_db_row(db_row, self.table)
|
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
|
|
|
|
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
|
|
|
|
2020-09-28 06:26:33 +00:00
|
|
|
def __str__(self):
|
|
|
|
if self.title:
|
|
|
|
return f'Album:{self.id}:{self.title}'
|
|
|
|
else:
|
|
|
|
return f'Album:{self.id}'
|
|
|
|
|
2018-03-23 06:39:11 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_description(description):
|
|
|
|
if description is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(description, str):
|
2020-09-19 10:32:16 +00:00
|
|
|
raise TypeError(f'Description must be {str}, 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):
|
2020-09-19 10:32:16 +00:00
|
|
|
raise TypeError(f'Title must be {str}, not {type(title)}.')
|
2018-03-23 06:39:11 +00:00
|
|
|
|
2020-09-19 10:50:55 +00:00
|
|
|
title = helpers.collapse_whitespace(title)
|
2018-03-23 06:39:11 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
2020-09-11 22:02:15 +00:00
|
|
|
def _add_associated_directory(self, path):
|
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
|
|
|
|
|
2020-09-15 02:38:13 +00:00
|
|
|
self.photodb.log.debug('Adding directory "%s" to %s.', path.absolute_path, self)
|
2018-07-29 23:30:30 +00:00
|
|
|
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
|
|
|
|
2020-09-11 22:02:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def add_associated_directory(self, path):
|
|
|
|
'''
|
|
|
|
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.
|
|
|
|
'''
|
|
|
|
self._add_associated_directory(path)
|
|
|
|
|
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def add_associated_directories(self, paths):
|
|
|
|
for path in paths:
|
|
|
|
self._add_associated_directory(path)
|
|
|
|
|
2020-09-11 22:21:13 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def add_child(self, *args, **kwargs):
|
|
|
|
return super().add_child(*args, **kwargs)
|
|
|
|
|
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def add_children(self, *args, **kwargs):
|
|
|
|
return super().add_children(*args, **kwargs)
|
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def add_photo(self, photo):
|
2016-12-17 04:02:08 +00:00
|
|
|
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
|
|
|
|
2018-03-11 06:50:41 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def add_photos(self, photos):
|
2018-03-11 06:50:41 +00:00
|
|
|
existing_photos = set(self.get_photos())
|
|
|
|
photos = set(photos)
|
|
|
|
photos = photos.difference(existing_photos)
|
|
|
|
|
|
|
|
for photo in photos:
|
|
|
|
self._add_photo(photo)
|
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def add_tag_to_all(self, tag, *, nested_children=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:
|
2020-02-20 06:20:21 +00:00
|
|
|
photo.add_tag(tag)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def delete(self, *, delete_children=False):
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Deleting %s', self)
|
2020-02-20 06:20:21 +00:00
|
|
|
GroupableMixin.delete(self, delete_children=delete_children)
|
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()
|
2020-09-20 18:16:07 +00:00
|
|
|
self.deleted = True
|
2016-12-17 04:02:08 +00:00
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def edit(self, title=None, description=None):
|
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:
|
2020-09-18 03:44:33 +00:00
|
|
|
title = self.normalize_title(title)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
if description is not None:
|
2020-09-18 03:44:33 +00:00
|
|
|
description = self.normalize_description(description)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
2020-09-18 03:44:33 +00:00
|
|
|
'title': title,
|
|
|
|
'description': description,
|
2018-02-17 04:19:18 +00:00
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='albums', pairs=data, where_key='id')
|
2020-09-18 03:44:33 +00:00
|
|
|
self.title = title
|
|
|
|
self.description = description
|
2017-11-12 23:21:53 +00:00
|
|
|
|
2020-09-28 06:23:54 +00:00
|
|
|
@property
|
|
|
|
def full_name(self):
|
|
|
|
if self.title:
|
|
|
|
return f'{self.id} - {self.title}'
|
|
|
|
else:
|
|
|
|
return self.id
|
|
|
|
|
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]
|
|
|
|
)
|
2020-09-20 20:41:22 +00:00
|
|
|
directories = set(pathclass.Path(directory) for (directory,) in directory_rows)
|
2018-02-17 21:33:27 +00:00
|
|
|
return directories
|
|
|
|
|
2018-02-17 07:03:54 +00:00
|
|
|
def get_photos(self):
|
|
|
|
photos = []
|
2020-09-20 20:41:22 +00:00
|
|
|
photo_rows = self.photodb.sql_select(
|
2018-02-17 07:03:54 +00:00
|
|
|
'SELECT photoid FROM album_photo_rel WHERE albumid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2020-09-20 20:41:22 +00:00
|
|
|
photo_ids = (photo_id for (photo_id,) in photo_rows)
|
2020-09-20 20:16:52 +00:00
|
|
|
photos = set(self.photodb.get_photos_by_id(photo_ids))
|
2018-02-17 07:03:54 +00:00
|
|
|
return photos
|
|
|
|
|
2019-04-27 01:55:03 +00:00
|
|
|
def has_any_associated_directory(self):
|
|
|
|
'''
|
|
|
|
Return True if this album has at least 1 associated directory.
|
|
|
|
'''
|
|
|
|
row = self.photodb.sql_select_one(
|
|
|
|
'SELECT 1 FROM album_associated_directories WHERE albumid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
|
|
|
return row is not None
|
|
|
|
|
2018-04-15 09:41:24 +00:00
|
|
|
def has_any_photo(self, recurse=False):
|
2019-03-16 20:09:02 +00:00
|
|
|
'''
|
|
|
|
Return True if this album contains at least 1 photo.
|
|
|
|
|
|
|
|
recurse:
|
|
|
|
If True, photos in child albums satisfy.
|
|
|
|
If False, only consider this album.
|
|
|
|
'''
|
2018-04-15 09:41:24 +00:00
|
|
|
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')
|
2020-02-20 07:10:49 +00:00
|
|
|
@decorators.transaction
|
2018-07-23 04:06:16 +00:00
|
|
|
def remove_child(self, *args, **kwargs):
|
|
|
|
return super().remove_child(*args, **kwargs)
|
|
|
|
|
2020-09-28 05:24:33 +00:00
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def remove_children(self, *args, **kwargs):
|
|
|
|
return super().remove_children(*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
|
2020-02-20 07:56:09 +00:00
|
|
|
def remove_photo(self, photo):
|
2018-05-02 01:05:07 +00:00
|
|
|
self._remove_photo(photo)
|
|
|
|
|
|
|
|
@decorators.required_feature('album.edit')
|
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def remove_photos(self, photos):
|
2018-05-02 01:05:07 +00:00
|
|
|
existing_photos = set(self.get_photos())
|
|
|
|
photos = set(photos)
|
|
|
|
photos = photos.intersection(existing_photos)
|
|
|
|
|
|
|
|
for photo in photos:
|
|
|
|
self._remove_photo(photo)
|
|
|
|
|
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):
|
2019-04-27 01:52:56 +00:00
|
|
|
table = 'bookmarks'
|
|
|
|
|
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)
|
2019-04-27 01:52:56 +00:00
|
|
|
db_row = normalize_db_row(db_row, self.table)
|
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):
|
2020-09-19 10:32:16 +00:00
|
|
|
raise TypeError(f'Title must be {str}, not {type(title)}.')
|
2018-03-23 06:47:53 +00:00
|
|
|
|
2020-09-19 10:50:55 +00:00
|
|
|
title = helpers.collapse_whitespace(title)
|
2018-03-23 06:47:53 +00:00
|
|
|
|
|
|
|
return title
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def normalize_url(url):
|
|
|
|
if url is None:
|
|
|
|
return ''
|
|
|
|
|
|
|
|
if not isinstance(url, str):
|
2020-09-19 10:32:16 +00:00
|
|
|
raise TypeError(f'URL must be {str}, not {type(url)}.')
|
2018-03-23 06:47:53 +00:00
|
|
|
|
|
|
|
url = url.strip()
|
|
|
|
|
|
|
|
if not url:
|
2020-09-19 10:52:42 +00:00
|
|
|
raise ValueError(f'URL can not be blank.')
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def delete(self):
|
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()
|
2020-09-20 18:16:07 +00:00
|
|
|
self.deleted = True
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def edit(self, title=None, url=None):
|
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:
|
2020-09-18 03:44:33 +00:00
|
|
|
title = self.normalize_title(title)
|
2017-02-05 02:30:02 +00:00
|
|
|
|
|
|
|
if url is not None:
|
2020-09-18 03:44:33 +00:00
|
|
|
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,
|
2020-09-18 03:44:33 +00:00
|
|
|
'title': title,
|
|
|
|
'url': url,
|
2018-02-17 04:19:18 +00:00
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='bookmarks', pairs=data, where_key='id')
|
2020-09-18 03:44:33 +00:00
|
|
|
self.title = title
|
|
|
|
self.url = url
|
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.
|
|
|
|
'''
|
2019-04-27 01:52:56 +00:00
|
|
|
table = 'photos'
|
|
|
|
|
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)
|
2019-04-27 01:52:56 +00:00
|
|
|
db_row = normalize_db_row(db_row, self.table)
|
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'])
|
2020-09-28 21:06:32 +00:00
|
|
|
self.override_filename = db_row['override_filename']
|
2017-03-04 05:15:31 +00:00
|
|
|
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
|
|
|
|
2020-09-28 06:26:33 +00:00
|
|
|
def __str__(self):
|
|
|
|
return f'Photo:{self.id}:{self.basename}'
|
|
|
|
|
2020-09-18 00:46:52 +00:00
|
|
|
@staticmethod
|
|
|
|
def normalize_override_filename(override_filename):
|
|
|
|
if override_filename is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
cleaned = helpers.remove_path_badchars(override_filename)
|
|
|
|
cleaned = cleaned.strip()
|
|
|
|
if not cleaned:
|
|
|
|
raise ValueError(f'"{override_filename}" is not valid.')
|
|
|
|
|
|
|
|
return cleaned
|
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def add_tag(self, tag):
|
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)
|
2020-02-20 06:20:21 +00:00
|
|
|
self.remove_tag(parent)
|
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
|
|
|
|
2017-03-10 22:04:50 +00:00
|
|
|
return tag
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2020-09-28 21:06:32 +00:00
|
|
|
@property
|
|
|
|
def basename(self):
|
|
|
|
return self.override_filename or self.real_path.basename
|
|
|
|
|
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
|
|
|
|
2020-02-20 07:10:49 +00:00
|
|
|
# Photo.add_tag already has @required_feature add_remove_tag
|
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def copy_tags(self, other_photo):
|
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():
|
2020-02-20 06:20:21 +00:00
|
|
|
self.add_tag(tag)
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def delete(self, *, delete_file=False):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
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
|
2020-02-27 22:18:46 +00:00
|
|
|
if self.photodb.config['recycle_instead_of_delete']:
|
|
|
|
self.photodb.log.debug('Recycling %s', path)
|
|
|
|
action = send2trash.send2trash
|
|
|
|
else:
|
|
|
|
self.photodb.log.debug('Deleting %s', path)
|
|
|
|
action = os.remove
|
2020-09-28 21:22:09 +00:00
|
|
|
|
|
|
|
self.photodb.on_commit_queue.append({
|
|
|
|
'action': action,
|
|
|
|
'args': [path],
|
|
|
|
})
|
|
|
|
if self.thumbnail and self.thumbnail.is_file:
|
|
|
|
self.photodb.on_commit_queue.append({
|
|
|
|
'action': action,
|
|
|
|
'args': [self.thumbnail.absolute_path],
|
|
|
|
})
|
|
|
|
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2020-09-20 18:16:07 +00:00
|
|
|
self.deleted = True
|
2016-12-17 04:02:08 +00:00
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def generate_thumbnail(self, **special):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
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
|
|
|
|
|
|
|
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.
|
|
|
|
'''
|
2020-09-20 20:41:22 +00:00
|
|
|
album_rows = self.photodb.sql_select(
|
2018-03-27 03:07:42 +00:00
|
|
|
'SELECT albumid FROM album_photo_rel WHERE photoid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2020-09-20 20:41:22 +00:00
|
|
|
album_ids = (album_id for (album_id,) in album_rows)
|
2020-09-14 12:16:24 +00:00
|
|
|
albums = set(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.
|
|
|
|
'''
|
2020-09-20 20:41:22 +00:00
|
|
|
tag_rows = self.photodb.sql_select(
|
2018-02-17 07:07:21 +00:00
|
|
|
'SELECT tagid FROM photo_tag_rel WHERE photoid == ?',
|
|
|
|
[self.id]
|
|
|
|
)
|
2020-09-20 20:41:22 +00:00
|
|
|
tag_ids = (tag_id for (tag_id,) in tag_rows)
|
2020-09-15 01:34:14 +00:00
|
|
|
tags = set(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.
|
|
|
|
'''
|
2020-08-29 00:57:15 +00:00
|
|
|
chunked_id = [''.join(chunk) for chunk in 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:
|
2020-09-24 21:18:23 +00:00
|
|
|
folder.makedirs(exist_ok=True)
|
2016-12-25 01:13:45 +00:00
|
|
|
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
|
2020-02-20 07:10:49 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def move_file(self, directory):
|
2018-09-23 21:54:56 +00:00
|
|
|
directory = pathclass.Path(directory)
|
|
|
|
directory.assert_is_directory()
|
2020-02-20 00:23:31 +00:00
|
|
|
new_path = directory.with_child(self.real_path.basename)
|
|
|
|
new_path.assert_not_exists()
|
2020-02-20 07:56:09 +00:00
|
|
|
self.rename_file(new_path.absolute_path, move=True)
|
2018-09-23 21:54:56 +00:00
|
|
|
|
2020-08-29 00:51:07 +00:00
|
|
|
def _reload_image_metadata(self):
|
|
|
|
try:
|
|
|
|
image = PIL.Image.open(self.real_path.absolute_path)
|
|
|
|
except (OSError, ValueError):
|
|
|
|
traceback.print_exc()
|
|
|
|
return
|
|
|
|
|
|
|
|
(self.width, self.height) = image.size
|
|
|
|
image.close()
|
|
|
|
|
|
|
|
def _reload_video_metadata(self):
|
|
|
|
if not constants.ffmpeg:
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
|
|
|
probe = constants.ffmpeg.probe(self.real_path.absolute_path)
|
|
|
|
except Exception:
|
|
|
|
traceback.print_exc()
|
|
|
|
return
|
|
|
|
|
|
|
|
if not probe or not probe.video:
|
|
|
|
return
|
|
|
|
|
|
|
|
self.width = probe.video.video_width
|
|
|
|
self.height = probe.video.video_height
|
|
|
|
self.duration = probe.format.duration or probe.video.duration
|
|
|
|
|
|
|
|
def _reload_audio_metadata(self):
|
|
|
|
if not constants.ffmpeg:
|
|
|
|
return
|
|
|
|
|
|
|
|
try:
|
|
|
|
probe = constants.ffmpeg.probe(self.real_path.absolute_path)
|
|
|
|
except Exception:
|
|
|
|
traceback.print_exc()
|
|
|
|
return
|
|
|
|
|
|
|
|
if not probe or not probe.audio:
|
|
|
|
return
|
|
|
|
|
|
|
|
self.duration = probe.audio.duration
|
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def reload_metadata(self):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
Load the file's height, width, etc as appropriate for this type of file.
|
|
|
|
'''
|
2019-04-02 06:17:01 +00:00
|
|
|
self.photodb.log.debug('Reloading metadata for %s', self)
|
|
|
|
|
2020-08-29 00:32:53 +00:00
|
|
|
self.bytes = None
|
2020-09-15 21:56:45 +00:00
|
|
|
self.dev_ino = None
|
2016-12-17 04:02:08 +00:00
|
|
|
self.width = None
|
|
|
|
self.height = None
|
|
|
|
self.area = None
|
|
|
|
self.ratio = None
|
|
|
|
self.duration = None
|
|
|
|
|
2020-09-15 21:56:45 +00:00
|
|
|
if self.real_path.is_file:
|
|
|
|
stat = self.real_path.stat
|
|
|
|
self.bytes = stat.st_size
|
|
|
|
(dev, ino) = (stat.st_dev, stat.st_ino)
|
|
|
|
if dev and ino:
|
|
|
|
self.dev_ino = f'{dev},{ino}'
|
2020-08-29 00:32:53 +00:00
|
|
|
|
|
|
|
if self.bytes is None:
|
|
|
|
pass
|
|
|
|
|
|
|
|
elif self.simple_mimetype == 'image':
|
2020-08-29 00:51:07 +00:00
|
|
|
self._reload_image_metadata()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2020-08-29 00:51:07 +00:00
|
|
|
elif self.simple_mimetype == 'video':
|
|
|
|
self._reload_video_metadata()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2020-08-29 00:51:07 +00:00
|
|
|
elif self.simple_mimetype == 'audio':
|
|
|
|
self._reload_audio_metadata()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
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,
|
2020-09-15 21:56:45 +00:00
|
|
|
'dev_ino': self.dev_ino,
|
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')
|
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
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-09-28 21:18:10 +00:00
|
|
|
def relocate(self, new_filepath):
|
2017-03-23 06:18:09 +00:00
|
|
|
'''
|
|
|
|
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`.
|
|
|
|
'''
|
|
|
|
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
|
|
|
|
2020-09-28 21:18:10 +00:00
|
|
|
self.photodb.assert_no_such_photo_by_path(filepath=new_filepath)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2020-09-15 02:38:29 +00:00
|
|
|
self.photodb.log.debug('Relocating %s to "%s"', self, new_filepath.absolute_path)
|
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')
|
2020-09-18 03:38:17 +00:00
|
|
|
self.real_path = new_filepath
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2017-03-23 06:18:09 +00:00
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def remove_tag(self, tag):
|
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)
|
2020-09-14 12:37:13 +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
|
|
|
|
2020-09-14 12:38:20 +00:00
|
|
|
@decorators.required_feature('photo.add_remove_tag')
|
|
|
|
@decorators.transaction
|
|
|
|
def remove_tags(self, tags):
|
|
|
|
tags = [self.photodb.get_tag(name=tag) for tag in tags]
|
|
|
|
|
|
|
|
self.photodb.log.debug('Removing %s from %s', tags, self)
|
|
|
|
query = f'''
|
|
|
|
DELETE FROM photo_tag_rel
|
|
|
|
WHERE tagid IN {sqlhelpers.listify(tag.id for tag in tags)}
|
|
|
|
'''
|
|
|
|
self.photodb.sql_execute(query)
|
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'tagged_at': helpers.now(),
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def rename_file(self, new_filename, *, move=False):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
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
|
|
|
if (new_path.parent != old_path.parent) and not move:
|
2020-09-19 10:32:16 +00:00
|
|
|
raise ValueError('Cannot move the file without param move=True.')
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
if new_path.absolute_path == old_path.absolute_path:
|
2020-09-19 10:32:16 +00:00
|
|
|
raise ValueError('The new and old names are the same.')
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2020-02-20 07:12:29 +00:00
|
|
|
new_path.assert_not_exists()
|
|
|
|
|
2020-09-20 18:41:33 +00:00
|
|
|
self.photodb.log.debug('Renaming file "%s" -> "%s"', old_path.absolute_path, new_path.absolute_path)
|
|
|
|
|
2020-09-24 21:18:23 +00:00
|
|
|
new_path.parent.makedirs(exist_ok=True)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2020-02-20 07:22:08 +00:00
|
|
|
# The plan is to make a hardlink now, then delete the original file
|
|
|
|
# during commit. This only applies to normcase != normcase, because on
|
|
|
|
# case-insensitive systems (Windows), if we're trying to rename "AFILE"
|
|
|
|
# to "afile", we will not be able to hardlink nor copy the file to the
|
|
|
|
# new name, we'll just want to do an os.rename during commit.
|
2017-02-26 06:47:20 +00:00
|
|
|
if new_path.normcase != old_path.normcase:
|
2020-02-20 07:22:08 +00:00
|
|
|
# If we're on the same partition, make a hardlink.
|
|
|
|
# Otherwise make a copy.
|
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')
|
2020-09-18 03:38:17 +00:00
|
|
|
self.real_path = new_path
|
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.
|
2020-02-20 07:46:30 +00:00
|
|
|
self.photodb.on_commit_queue.append({
|
|
|
|
'action': os.rename,
|
|
|
|
'args': [old_path.absolute_path, new_path.absolute_path],
|
|
|
|
})
|
2017-02-26 06:47:20 +00:00
|
|
|
else:
|
|
|
|
# Delete the original, leaving only the new copy / hardlink.
|
2020-02-20 07:46:30 +00:00
|
|
|
self.photodb.on_commit_queue.append({
|
|
|
|
'action': os.remove,
|
|
|
|
'args': [old_path.absolute_path],
|
|
|
|
})
|
|
|
|
self.photodb.on_rollback_queue.append({
|
|
|
|
'action': os.remove,
|
|
|
|
'args': [new_path.absolute_path],
|
|
|
|
})
|
2017-03-23 07:04:44 +00:00
|
|
|
|
|
|
|
self._uncache()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
self.__reinit__()
|
|
|
|
|
2018-02-26 02:55:46 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def set_override_filename(self, new_filename):
|
2020-09-18 00:46:52 +00:00
|
|
|
new_filename = self.normalize_override_filename(new_filename)
|
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')
|
2020-09-28 21:06:32 +00:00
|
|
|
self.override_filename = new_filename
|
2018-02-17 23:07:26 +00:00
|
|
|
|
2018-02-17 23:47:26 +00:00
|
|
|
self.__reinit__()
|
|
|
|
|
2020-09-18 20:11:56 +00:00
|
|
|
@decorators.required_feature('photo.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def set_searchhidden(self, searchhidden):
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
|
|
|
'searchhidden': bool(searchhidden),
|
|
|
|
}
|
|
|
|
self.photodb.sql_update(table='photos', pairs=data, where_key='id')
|
|
|
|
self.searchhidden = searchhidden
|
|
|
|
|
2016-12-17 04:02:08 +00:00
|
|
|
class Tag(ObjectBase, GroupableMixin):
|
|
|
|
'''
|
|
|
|
A Tag, which can be applied to Photos for organization.
|
|
|
|
'''
|
2019-04-27 01:52:56 +00:00
|
|
|
table = 'tags'
|
2017-03-04 09:13:22 +00:00
|
|
|
group_table = 'tag_group_rel'
|
|
|
|
|
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)
|
2019-04-27 01:52:56 +00:00
|
|
|
db_row = normalize_db_row(db_row, self.table)
|
2018-07-20 05:42:21 +00:00
|
|
|
|
2019-04-27 01:52:56 +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
|
|
|
|
2018-04-15 09:14:06 +00:00
|
|
|
self.group_getter_many = self.photodb.get_tags_by_id
|
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):
|
2020-09-19 10:32:16 +00:00
|
|
|
raise TypeError(f'Description must be {str}, 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
|
2020-09-18 01:52:00 +00:00
|
|
|
def normalize_name(name, min_length=None, max_length=None):
|
2018-04-20 22:28:27 +00:00
|
|
|
original_name = name
|
2020-09-18 01:52:00 +00:00
|
|
|
# if valid_chars is None:
|
|
|
|
# valid_chars = constants.DEFAULT_CONFIGURATION['tag']['valid_chars']
|
2018-04-20 22:28:27 +00:00
|
|
|
|
2020-09-18 01:52:00 +00:00
|
|
|
name = name.lower()
|
|
|
|
name = helpers.remove_control_characters(name)
|
|
|
|
name = name.strip(' .+')
|
2018-07-20 05:42:21 +00:00
|
|
|
name = name.split('+')[0].split('.')[-1]
|
2018-04-20 22:28:27 +00:00
|
|
|
name = name.replace('-', '_')
|
|
|
|
name = name.replace(' ', '_')
|
2020-09-18 01:52:00 +00:00
|
|
|
name = name.replace('=', '')
|
|
|
|
# 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)
|
|
|
|
|
2020-09-14 12:46:43 +00:00
|
|
|
def _add_child(self, member):
|
|
|
|
ret = super()._add_child(member)
|
|
|
|
if ret is BAIL:
|
|
|
|
return BAIL
|
|
|
|
|
|
|
|
# Suppose a photo has tags A and B. Then, B is added as a child of A.
|
|
|
|
# We should remove A from the photo leaving only the more specific B.
|
|
|
|
# We must walk all ancestors, not just the immediate parent, because
|
|
|
|
# the same situation could apply to a photo that has tag A, where A
|
|
|
|
# already has children B.C.D, and then E is added as a child of D,
|
|
|
|
# obsoleting A.
|
|
|
|
# I expect that this method, which calls `search`, will be inefficient
|
|
|
|
# when used in a large `add_children` loop. I would consider batching
|
|
|
|
# up all the ancestors and doing it all at once. Just need to make sure
|
|
|
|
# I don't cause any collateral damage e.g. removing A from a photo that
|
|
|
|
# only has A because some other photo with A and B thinks A is obsolete.
|
|
|
|
# This technique is nice and simple to understand for now.
|
|
|
|
ancestors = list(member.walk_parents())
|
|
|
|
photos = self.photodb.search(tag_musts=[member], is_searchhidden=None, yield_albums=False)
|
|
|
|
for photo in photos:
|
|
|
|
photo.remove_tags(ancestors)
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2020-02-20 07:10:49 +00:00
|
|
|
@decorators.transaction
|
2017-11-12 06:41:26 +00:00
|
|
|
def add_child(self, *args, **kwargs):
|
2020-09-12 08:19:03 +00:00
|
|
|
ret = super().add_child(*args, **kwargs)
|
|
|
|
if ret is BAIL:
|
|
|
|
return
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
2020-09-12 08:19:03 +00:00
|
|
|
return ret
|
2017-06-15 06:15:47 +00:00
|
|
|
|
2018-07-20 05:42:21 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2020-02-20 07:10:49 +00:00
|
|
|
@decorators.transaction
|
2018-07-20 05:42:21 +00:00
|
|
|
def add_children(self, *args, **kwargs):
|
2020-09-12 08:19:03 +00:00
|
|
|
ret = super().add_children(*args, **kwargs)
|
|
|
|
if ret is BAIL:
|
|
|
|
return
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
2020-09-12 08:19:03 +00:00
|
|
|
return ret
|
2018-07-20 05:42:21 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def add_synonym(self, synname):
|
2016-12-17 04:02:08 +00:00
|
|
|
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
|
|
|
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
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
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def convert_to_synonym(self, mastertag):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
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
|
|
|
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
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-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')
|
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]
|
2020-09-20 20:41:22 +00:00
|
|
|
photo_rows = self.photodb.sql_execute(query, bindings)
|
|
|
|
replace_photoids = [photo_id for (photo_id,) in photo_rows]
|
2018-03-27 03:07:42 +00:00
|
|
|
|
|
|
|
# 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.
|
2020-02-20 06:20:21 +00:00
|
|
|
self.delete()
|
2016-12-17 04:02:08 +00:00
|
|
|
|
|
|
|
# Enjoy your new life as a monk.
|
2020-02-20 06:20:21 +00:00
|
|
|
mastertag.add_synonym(self.name)
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def delete(self, *, delete_children=False):
|
2018-03-11 09:34:32 +00:00
|
|
|
self.photodb.log.debug('Deleting %s', self)
|
2020-09-12 08:19:03 +00:00
|
|
|
super().delete(delete_children=delete_children)
|
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})
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
2017-03-23 07:04:44 +00:00
|
|
|
self._uncache()
|
2020-09-20 18:16:07 +00:00
|
|
|
self.deleted = True
|
2016-12-17 04:02:08 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-13 00:31:17 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def edit(self, description=None):
|
2017-05-13 00:31:17 +00:00
|
|
|
'''
|
|
|
|
Change the description. Leave None to keep current value.
|
|
|
|
'''
|
|
|
|
if description is None:
|
2018-02-17 04:19:18 +00:00
|
|
|
return
|
|
|
|
|
2020-09-18 03:44:33 +00:00
|
|
|
description = self.normalize_description(description)
|
2018-02-17 04:19:18 +00:00
|
|
|
|
|
|
|
data = {
|
|
|
|
'id': self.id,
|
2020-09-18 03:44:33 +00:00
|
|
|
'description': description,
|
2018-02-17 04:19:18 +00:00
|
|
|
}
|
2018-02-17 22:01:12 +00:00
|
|
|
self.photodb.sql_update(table='tags', pairs=data, where_key='id')
|
2020-09-18 03:44:33 +00:00
|
|
|
self.description = description
|
2018-02-17 04:19:18 +00:00
|
|
|
|
2017-05-13 00:31:17 +00:00
|
|
|
self._uncache()
|
|
|
|
|
2018-02-17 07:01:07 +00:00
|
|
|
def get_synonyms(self):
|
2018-03-27 03:07:42 +00:00
|
|
|
syn_rows = self.photodb.sql_select(
|
|
|
|
'SELECT name FROM tag_synonyms WHERE mastername == ?',
|
|
|
|
[self.name]
|
|
|
|
)
|
2020-09-20 20:41:22 +00:00
|
|
|
synonyms = set(name for (name,) in syn_rows)
|
2018-05-07 04:04:19 +00:00
|
|
|
return synonyms
|
2018-02-17 07:01:07 +00:00
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2020-02-20 07:10:49 +00:00
|
|
|
@decorators.transaction
|
2018-07-23 04:06:16 +00:00
|
|
|
def remove_child(self, *args, **kwargs):
|
2020-09-12 08:19:03 +00:00
|
|
|
ret = super().remove_child(*args, **kwargs)
|
|
|
|
if ret is BAIL:
|
|
|
|
return
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
2020-09-12 08:19:03 +00:00
|
|
|
return ret
|
2018-07-23 04:06:16 +00:00
|
|
|
|
2020-09-28 05:24:33 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
|
|
|
@decorators.transaction
|
|
|
|
def remove_children(self, *args, **kwargs):
|
|
|
|
ret = super().remove_children(*args, **kwargs)
|
|
|
|
if ret is BAIL:
|
|
|
|
return
|
|
|
|
self.photodb.caches['tag_exports'].clear()
|
|
|
|
return ret
|
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def remove_synonym(self, synname):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
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)
|
|
|
|
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
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
|
|
|
|
2017-07-29 23:23:15 +00:00
|
|
|
@decorators.required_feature('tag.edit')
|
2017-05-02 03:23:58 +00:00
|
|
|
@decorators.transaction
|
2020-02-20 07:56:09 +00:00
|
|
|
def rename(self, new_name, *, apply_to_synonyms=True):
|
2016-12-17 04:02:08 +00:00
|
|
|
'''
|
|
|
|
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
|
|
|
|
2020-09-14 12:49:24 +00:00
|
|
|
self.photodb.caches['tag_exports'].clear()
|
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
|
|
|
|
|
|
|
class User(ObjectBase):
|
|
|
|
'''
|
|
|
|
A dear friend of ours.
|
|
|
|
'''
|
2019-04-27 01:52:56 +00:00
|
|
|
table = 'users'
|
|
|
|
|
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)
|
2019-04-27 01:52:56 +00:00
|
|
|
db_row = normalize_db_row(db_row, self.table)
|
|
|
|
|
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
|
|
|
|
2020-09-19 10:50:55 +00:00
|
|
|
display_name = helpers.collapse_whitespace(display_name)
|
2018-04-15 21:23:24 +00:00
|
|
|
|
|
|
|
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
|
2020-02-20 07:56:09 +00:00
|
|
|
def set_display_name(self, display_name):
|
2018-04-15 21:23:24 +00:00
|
|
|
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
|
|
|
|
|
2016-12-25 01:13:45 +00:00
|
|
|
class WarningBag:
|
|
|
|
def __init__(self):
|
|
|
|
self.warnings = set()
|
|
|
|
|
|
|
|
def add(self, warning):
|
|
|
|
self.warnings.add(warning)
|