Half-done collections and UI changes

Minor tweaks and refactors. A half-baked collection support and several UI tweaks.

Just committing these are they are "old" changes and the current state turns out to be pretty stable.
dev
Peery
parent a66c601410
commit b37a21d785

@ -14,19 +14,22 @@ from ArtNet.gui.importer_window import ImporterWindow
from ArtNet.gui.browse_window import BrowseWindow
from ArtNet.gui.dialogs.db_connection_dialog.db_dialog import DBDialog
from ArtNet.web.link_generator import LinkGenerator
from ArtNet.singleton_manager import SingletonManager
class ArtNetManager:
LOG_FOLDER = "log"
def __init__(self, config_location: str = "."):
SingletonManager.set_instance(self)
if not os.path.isdir(ArtNetManager.LOG_FOLDER):
os.mkdir(ArtNetManager.LOG_FOLDER)
file_name = "artnet-" + str(datetime.datetime.now().strftime("%Y-%m-%d")) + ".log"
logging.basicConfig(filename=os.path.join(ArtNetManager.LOG_FOLDER, file_name), encoding='utf-8',
level=logging.DEBUG)
logFormatter = logging.Formatter(fmt="%(asctime)s.%(msecs)03d [%(threadName)-12.12s] [%(levelname)-5.5s] "
"%(message)s",
"%(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
@ -182,7 +185,7 @@ class ArtNetManager:
if tag in already_applied_tags: # tag is already applied
continue
result = self.db_connection.get_tag_impliers(tag)
result = self.db_connection.get_tag_impliers_by_name(tag)
if len(result) != 0: # tag is implied by some other tag
skip = False
for implier_tag_id in result:
@ -389,10 +392,12 @@ class ArtNetManager:
s = self.all_images[self.curr_image_index].split(os.path.sep)
if image_db_result is not None:
image_title = image_db_result["title"] if isinstance(image_db_result["title"], str) and \
len(image_db_result["title"]) > 0 else self.import_window.UNKNOWN_TITLE
len(image_db_result[
"title"]) > 0 else self.import_window.UNKNOWN_TITLE
image_author = self.db_connection.get_authors_of_art_by_ID(image_db_result["ID"])
image_link = image_db_result["link"]
image_description = image_db_result["description"]
collections = self.db_connection.get_collections_of_art_by_ID(image_db_result["ID"])
art_ID = image_db_result["ID"]
if image_author is None or len(image_author) == 0:
image_author = [(self.all_images[self.curr_image_index].split(os.path.sep)[0], "(Not in Database)")]
@ -401,13 +406,16 @@ class ArtNetManager:
image_author = [(s[0], "(Not in Database)")]
image_title = s[-1] + " (Not in Database)"
image_link = "(Unknown)"
collections = []
image_description = None
logging.info(f"Displaying #{self.curr_image_index} \"{self.all_images[self.curr_image_index]}\"")
self.curr_active_window.display_image(image_title, image_author,
os.path.join(self.config.data["file_root"], self.all_images[self.curr_image_index]),
self.all_images[self.curr_image_index],
art_ID, image_link, file_name=s[-1], description=image_description)
os.path.join(self.config.data["file_root"],
self.all_images[self.curr_image_index]),
self.all_images[self.curr_image_index],
art_ID, image_link, file_name=s[-1], description=image_description,
collections=collections)
self.curr_active_window.set_tag_list(
[self.db_connection.get_tag_by_ID(x)[0][1].strip() for x in self.db_connection.get_art_tags_by_ID(art_ID)])
@ -431,7 +439,7 @@ class ArtNetManager:
"""
if len(path) == 0:
exit(0)
logging.info("Changing root to", path)
logging.info(f"Changing root to {path}")
self.config.data["file_root"] = path
self.config.update_config()
self.__file_reader = FileReader(self.config.data["file_root"])
@ -442,6 +450,12 @@ class ArtNetManager:
return self.config.data["db"]
def change_db_connection(self, host: str, port: int, database: str, user: str, password: str):
try:
self.db_connection = self.create_db_connection(host, port, database, user, password)
except ValueError as e:
logging.error(f"Error encountered during change_db_connection(): {e}")
raise e
self.config.data["db"]["user"] = user
self.config.data["db"]["password"] = password
self.config.data["db"]["host"] = host
@ -449,5 +463,3 @@ class ArtNetManager:
self.config.data["db"]["port"] = str(port)
self.config.update_config()
self.db_connection = self.create_db_connection(host, port, database, user, password)

@ -1,10 +1,11 @@
import logging
from typing import List, Tuple
import psycopg2
from psycopg2.errorcodes import UNIQUE_VIOLATION
class DBAdapter:
EXPECTED_TABLES = ['art', 'artist', 'tag', 'topic', 'presence', 'artist_to_topic', 'art_to_presence',
'art_to_tag', 'tag_alias', 'tag_implication', 'tag_category', 'art_collection',
'art_to_art_collection']
@ -30,8 +31,13 @@ class DBAdapter:
Check if all the expected tables are present in the connected database.
:return:
"""
self.db_cursor.execute("select relname from pg_class where relkind='r' and relname !~ '^(pg_|sql_)';")
present_tables = self.db_cursor.fetchall()
try:
self.db_cursor.execute("select relname from pg_class where relkind='r' and relname !~ '^(pg_|sql_)';")
present_tables = self.db_cursor.fetchall()
except psycopg2.DatabaseError as e:
logging.error(f"Database error occured on check_tables(): {e}")
self.db.rollback()
return False
unknown_tables = []
missing = [x for x in DBAdapter.EXPECTED_TABLES]
for table in present_tables:
@ -46,11 +52,13 @@ class DBAdapter:
return False
if len(unknown_tables) > 0:
logging.error("The following tables are unknown and not expected inside the database: {0}".format(unknown_tables))
logging.error(
"The following tables are unknown and not expected inside the database: {0}".format(unknown_tables))
return True
def save_image(self, ID: str, title: str, authors: list, path: str, tags: list, link: str, md5_hash: str, desc: str):
def save_image(self, ID: str, title: str, authors: list, path: str, tags: list, link: str, md5_hash: str,
desc: str, collections: list):
"""
Updates or saves the given image data to the DB
:param ID:
@ -61,30 +69,34 @@ class DBAdapter:
:param link:
:param md5_hash: md5 hash as a hex digest
:param desc: image description or None for empty
:param collections: list of collections included by the image
:return:
"""
logging.debug("Saving Image {0}:{1} authors: {2} path: {3} tags: {4} link: {5} hash:{6} desc:{7}"
.format(ID, title, authors, path, tags, link, md5_hash, desc))
d = {"title": title, "path": path, "id": ID, "link": link, "hash": md5_hash, "desc": desc}
.format(ID, title, authors, path, tags, link, md5_hash, desc))
d = {"title": title, "path": path, "id": ID, "link": link, "hash": md5_hash, "desc": desc} # TODO implement try/except for database errors and rollback!
if self.get_art_by_path(path) is None:
if ID is None:
self.db_cursor.execute(
"INSERT INTO art (path, title, link, md5_hash, description) "
"VALUES (%(path)s, %(title)s, %(link)s, %(hash)s, %(desc)s)",
d)
self.db.commit()
ID = self.get_art_by_path(path)["ID"]
d["id"] = ID
elif self.get_tag_by_ID(ID) is None:
self.db_cursor.execute(
"INSERT INTO art (path, title, link, md5_hash, description) "
"VALUES (%(path)s, %(title)s, %(link)s, %(hash)s, %(desc)s)", d)
self.db.commit()
else:
self.db_cursor.execute("UPDATE art SET path = %(path)s, title = %(title)s, link = %(link)s, " +
"md5_hash = %(hash)s, description = %(desc)s WHERE id = %(id)s", d)
self.db.commit()
if ID is None:
ID = self.get_art_by_path(path)["ID"]
assert(ID != None) # was checked before, should never fail
assert (ID != None) # was checked before, should never fail
old_tags = [self.get_tag_by_ID(x)[0][1].strip() for x in self.get_art_tags_by_ID(ID)]
for tag in tags:
@ -93,10 +105,12 @@ class DBAdapter:
d = {"id": ID, "tag": self.get_tag_ID(tag)}
try:
self.db_cursor.execute("INSERT INTO art_to_tag (art_id, tag_ID) VALUES (%(id)s, %(tag)s)", d)
self.db.commit()
except psycopg2.Error as e:
if e.pgcode == UNIQUE_VIOLATION:
if e.pgcode == UNIQUE_VIOLATION: # tag was already set
logging.debug(e)
logging.info("Skipping Unique Violation ...")
self.db.rollback()
else:
raise e
@ -111,6 +125,7 @@ class DBAdapter:
d = {"id": ID, "author_name": author[0], "author_domain": author[1]}
self.db_cursor.execute("INSERT INTO art_to_presence (presence_name, presence_domain, art_ID) " +
"VALUES (%(author_name)s, %(author_domain)s, %(id)s)", d)
self.db.commit()
for old_author_name, old_author_domain in old_authors:
if (old_author_name, old_author_domain) not in authors: # need to remove old author
self.remove_artist_from_image(art_ID=ID, presence_name=old_author_name,
@ -130,16 +145,15 @@ class DBAdapter:
art_ID = image_data["ID"]
logging.debug(f"Deleting image #{art_ID} {image_data['title']}")
tags = self.get_art_tags_by_ID(art_ID)
for tag_ID in tags:
pass
#self.remove_tag_from_image(art_ID=art_ID, tag_ID=tag_ID)
# for tag_ID in tags:
# pass
# self.remove_tag_from_image(art_ID=art_ID, tag_ID=tag_ID)
authors = self.get_authors_of_art_by_ID(art_ID)
for presence_name, presence_domain in authors:
pass
#self.remove_artist_from_image(art_ID=art_ID, presence_name=presence_name, presence_domain=presence_domain)
#self.db_cursor.execute("DELETE FROM art WHERE md5_hash = %(hash)s", {"hash": hash})
# for presence_name, presence_domain in authors:
# pass
# self.remove_artist_from_image(art_ID=art_ID, presence_name=presence_name, presence_domain=presence_domain)
# self.db_cursor.execute("DELETE FROM art WHERE md5_hash = %(hash)s", {"hash": hash})
def remove_artist_from_image(self, art_ID, presence_name, presence_domain):
"""
@ -340,6 +354,29 @@ class DBAdapter:
"""
self.db_cursor.execute("SELECT id, name FROM artist")
def get_art_by_ID(self, ID: int):
"""
Queries the database for the art via its ID and returns it if available.
"""
d = {"ID": ID}
self.db_cursor.execute("SELECT id, path, title, link, md5_hash, description FROM art WHERE id = %(ID)s", d)
result = self.db_cursor.fetchall()
if len(result) == 0:
return None
else:
result = result[0]
image_data = {
"ID": result[0],
"path": result[1],
"title": result[2],
"link": result[3],
"md5_hash": result[4],
"description": result[5],
}
return image_data
def get_art_by_hash(self, file_hash: str) -> dict:
"""
Queries the database for the art via its hash and returns it if available.
@ -349,7 +386,8 @@ class DBAdapter:
:return:
"""
d = {"hash": file_hash}
self.db_cursor.execute("SELECT id, path, title, link, md5_hash, description FROM art where md5_hash = %(hash)s", d)
self.db_cursor.execute("SELECT id, path, title, link, md5_hash, description FROM art where md5_hash = %(hash)s",
d)
result = self.db_cursor.fetchall()
@ -449,6 +487,36 @@ class DBAdapter:
new_rows.append(row[0])
return new_rows
def search_art(self, tags: list, presences: list, resolve_tags: bool = True):
"""
Search with the tags and given presences for fitting art.
:param tags: List[int] list of tag ids that are to be present in the art. See resolve_tags for implication.
:param presences: List[Tuple(str, str)] list of presences to be present in the art.
:param resolve_tags: flag if tag implications should be resolved fully.
"""
d = {"tags": tags, "presences": presences}
l = list()
for i in range(len(presences)): # converting for psycopg2
name, domain = presences[i]
casted = [name, domain]
l.append(casted)
d["presences"] = l
search_query = "SELECT art.id FROM art " \
"INNER JOIN art_to_tag ON art_to_tag.art_id = art.id " \
"INNER JOIN tag ON art_to_tag.tag_id = tag.id " \
"INNER JOIN art_to_presence as AtP ON art.id = AtP.art_id " \
"GROUP BY art.id " \
"HAVING array_agg(tag.name) @> %(tags)s::varchar[] AND " \
"array_agg(array[AtP.presence_name, AtP.presence_domain]) @> %(presences)s::varchar[]"
# 1. Join all related tables
# 2. Group them over art.id
# 3. check which rows contain the searched tags or presences in the value arrays
self.db_cursor.execute(search_query, d)
result = self.db_cursor.fetchall()
return result
def search_fuzzy_presence(self, name: str, domain: str, all_if_empty: bool = False):
"""
Search a list of presences fitting the (name, domain) fuzzy.
@ -460,7 +528,7 @@ class DBAdapter:
if all_if_empty and (name is None or len(name) == 0) and (domain is None or len(domain) == 0):
self.db_cursor.execute("SELECT name, domain, artist_id FROM presence")
else:
d = {"name":"%"+name+"%", "domain": "%"+domain+"%"}
d = {"name": "%" + name + "%", "domain": "%" + domain + "%"}
self.db_cursor.execute("SELECT name, domain, artist_id FROM presence WHERE LOWER(name) LIKE "
"LOWER(%(name)s) AND domain LIKE %(domain)s", d)
rows = self.db_cursor.fetchall()
@ -502,7 +570,7 @@ class DBAdapter:
:param search:
:return:
"""
d = {"search": "%"+search+"%"}
d = {"search": "%" + search + "%"}
self.db_cursor.execute("SELECT name FROM tag_category WHERE LOWER(name) LIKE LOWER(%(search)s)", d)
rows = []
@ -519,10 +587,11 @@ class DBAdapter:
:return:
"""
if all_if_empty and len(name) == 0:
self.db_cursor.execute("SELECT name, description, category_id FROM tag")
self.db_cursor.execute("SELECT name, description, category_id, ID FROM tag")
else:
d = {"name": "%"+name+"%"}
self.db_cursor.execute("SELECT name, description, category_id FROM tag WHERE LOWER(name) LIKE LOWER(%(name)s)", d)
d = {"name": "%" + name + "%"}
self.db_cursor.execute(
"SELECT name, description, category_id, ID FROM tag WHERE LOWER(name) LIKE LOWER(%(name)s)", d)
rows = self.db_cursor.fetchall()
new_rows = []
@ -553,7 +622,7 @@ class DBAdapter:
elif all_if_empty and ID is None and len(name) == 0:
self.db_cursor.execute("SELECT id, name FROM artist")
else:
d = {"name": "%"+name+"%"}
d = {"name": "%" + name + "%"}
self.db_cursor.execute("SELECT id, name FROM artist WHERE LOWER(name) LIKE LOWER(%(name)s)", d)
return self.db_cursor.fetchall()
@ -600,7 +669,10 @@ class DBAdapter:
"category_id": category_id,
}
self.db_cursor.execute("UPDATE tag SET description = %(description)s, category_id = %(category_id)s, name = %(name)s WHERE tag_ID = %(id)s", d)
self.db_cursor.execute(
"UPDATE tag SET description = %(description)s, category_id = %(category_id)s, name = %(name)s "
"WHERE ID = %(id)s",
d)
if aliases is not None:
old_aliases = self.get_tag_aliases_by_ID(tag_id)
@ -684,7 +756,6 @@ class DBAdapter:
d)
self.db.commit()
def add_implication_by_name(self, name: str, implicant: str):
"""
Add the implication to the database
@ -725,7 +796,7 @@ class DBAdapter:
:return:
"""
d = {"tag": tag}
self.db_cursor.execute("DELETE FROM tag WHERE tag_id = %(tag)s", d)
self.db_cursor.execute("DELETE FROM tag WHERE id = %(tag)s", d)
self.db.commit()
def remove_tag_by_name(self, name: str):
@ -743,11 +814,15 @@ class DBAdapter:
:return:
"""
d = {"name": name}
self.db_cursor.execute("SELECT tag_ID FROM tag where LOWER(name) = LOWER(%(name)s)", d)
try:
self.db_cursor.execute("SELECT ID FROM tag where LOWER(name) = LOWER(%(name)s)", d)
rows = []
for row in self.db_cursor.fetchall():
rows.append(row[0])
rows = []
for row in self.db_cursor.fetchall():
rows.append(row[0])
except psycopg2.DatabaseError as e:
return None
if len(rows) > 1:
raise Exception("Found multiple tags by the same name!")
@ -765,7 +840,8 @@ class DBAdapter:
:return: Returns the tag's name, description, category, tag ID
"""
d = {"name": name}
self.db_cursor.execute("SELECT name, description, category_id, tag_ID FROM tag where LOWER(name) = LOWER(%(name)s)", d)
self.db_cursor.execute(
"SELECT name, description, category_id, ID FROM tag where LOWER(name) = LOWER(%(name)s)", d)
rows = []
for row in self.db_cursor.fetchall():
@ -789,13 +865,13 @@ class DBAdapter:
:return: Returns the tag's ID, name, description, category
"""
d = {"ID": ID}
self.db_cursor.execute("SELECT tag_ID, name, description, category_id FROM tag where tag_ID = %(ID)s", d)
self.db_cursor.execute("SELECT ID, name, description, category_id FROM tag where ID = %(ID)s", d)
return self.db_cursor.fetchall()
def get_tag_aliases_by_name(self, name: str) -> list:
"""
Search for the tag's aliases and the tag's aliases
Search for the tag's aliases and the alias's aliases
:param name:
:return: List of tag Names that are aliases of this one
"""
@ -803,7 +879,7 @@ class DBAdapter:
result = []
for alias in aliases:
tag_data = self.get_tag_by_ID(alias)
if len(tag_data) > 0:
if len(tag_data) > 0: # tag exists
result.append(tag_data[0][1].strip())
return result
@ -848,7 +924,8 @@ class DBAdapter:
collected_tags.append(found)
to_search.append(found)
collected_tags.remove(root_ID)
if root_ID in collected_tags:
collected_tags.remove(root_ID)
result = []
for tag_ID in collected_tags:
@ -857,6 +934,31 @@ class DBAdapter:
result.append(tag_data[0][1].strip())
return result
def get_all_tag_impliers_by_ID(self, ID: int) -> list:
"""
Search for all tags that imply this one or imply a tag that implies this one.
:param ID:
:return:
"""
collected_tags = self.get_tag_impliers_by_ID(ID)
to_search = self.get_tag_impliers_by_ID(ID)
collected_tags.append(ID)
while len(to_search) != 0:
curr_tag = to_search.pop()
found_impliers = self.get_tag_impliers_by_ID(curr_tag)
for found in found_impliers:
if found in collected_tags:
continue
else:
collected_tags.append(found)
to_search.append(found)
if ID in collected_tags:
collected_tags.remove(ID)
return collected_tags
def get_tag_implications_by_ID(self, ID: int) -> list:
"""
Search for the tag's implications
@ -908,20 +1010,29 @@ class DBAdapter:
return r
def get_tag_impliers(self, name: str) -> list:
def get_tag_impliers_by_ID(self, ID: int):
"""
Search for tags that imply this one.
:param ID:
:return:
"""
d = {"ID": ID}
self.db_cursor.execute("SELECT root_tag FROM Tag_Implication WHERE implicate = %(ID)s", d)
rows = self.db_cursor.fetchall()
return rows
def get_tag_impliers_by_name(self, name: str) -> list:
"""
Search for tags that imply this one.
:param name:
:return:
"""
d = {"ID": self.get_tag_ID(name)}
self.db_cursor.execute("SELECT root_tag, implicate FROM Tag_Implication WHERE implicate = %(ID)s", d)
self.db_cursor.execute("SELECT root_tag FROM Tag_Implication WHERE implicate = %(ID)s", d)
rows = self.db_cursor.fetchall()
r = []
for row in rows:
r.append(row[0])
return r
return rows
def __get_tag_aliases_no_recurse(self, tag_id: int) -> list:
"""
@ -946,3 +1057,140 @@ class DBAdapter:
raise Exception("Something went terribly wrong!")
return r
def create_collection(self, collection_name: str, description: str) -> int:
"""
Create the collection with name and description as given.
"""
d = {"name": collection_name, "description": description}
try:
self.db_cursor.execute("INSERT INTO art_collection (name, description) VALUES (%(name)s, %(description)s) "
"RETURNING id", d)
self.db.commit()
except psycopg2.DatabaseError as e:
logging.error(f"Encountered database error on create_collection() with collection_name: {collection_name} "
f"description: {description}. Rolling it back now ...")
logging.error(e)
self.db.rollback()
return None
return self.db_cursor.fetchall()[0]
def delete_collection(self, collection_id: int):
"""
Delete the collection given by the id.
"""
d = {"ID": collection_id}
try:
self.db_cursor.execute("DELETE FROM art_collection WHERE ID = %(ID)s", d)
self.db.commit()
except psycopg2.DatabaseError as e:
logging.error(f"Encountered database error on delete_collection() ID: {collection_id}. "
f"Rolling it back now ...")
logging.error(e)
self.db.rollback()
def get_collections_of_art_by_ID(self, art_id: int):
"""
Queries the database for collections that include the given art_id
:return: [(ID, name, description)]
"""
d = {"art_id": art_id}
self.db_cursor.execute("SELECT art_collection.id, art_collection.name, art_collection.description "
"FROM art_collection "
"INNER JOIN art_to_art_collection as atac ON art_collection.id = atac.collection_id "
"WHERE atac.art_id = %(art_id)s", d)
return self.db_cursor.fetchall()
def get_collection(self, collection_id: int):
"""
Returns the collection given by the id.
"""
d = {"ID": collection_id}
self.db_cursor.execute("SELECT collection.id, collection.name, collection.description, array_agg(art.id), "
"array_agg(art.title), array_agg(art.path), array_agg(atac.ranking) "
"FROM art_collection as collection "
"LEFT OUTER JOIN art_to_art_collection as atac ON atac.collection_id = collection.id "
"LEFT OUTER JOIN art ON atac.art_id = art.id "
"WHERE collection.ID = %(ID)s "
"GROUP BY collection.id", d)
r = self.db_cursor.fetchall()
if len(r) > 0:
r = r[0]
collection = dict()
collection["id"] = r[0]
collection["name"] = r[1]
collection["description"] = r[2]
collection["entries"] = []
for i in range(len(r[3])):
art_data = {"id": r[3][i], "title": r[4][i], "path": r[5][i], "ranking": r[6][i]}
if art_data["id"] is None: # skipping non-existing art entries
continue # might happen once when the collection has no entries
collection["entries"].append(art_data)
return collection
def search_collections_by_name(self, name: str) -> List[Tuple[int, str]]:
"""
Returns the collection by the given name
"""
d = {"name": name}
try:
self.db_cursor.execute("SELECT art_collection.id, art_collection.name "
"FROM art_collection "
"WHERE art_collection.name = %(name)s", d)
except psycopg2.DatabaseError as e:
logging.error(f"Encountered database error on get_collection_by_name() name: {name}."
f"Rolling it back now ...")
logging.error(e)
self.db.rollback()
return None
return self.db_cursor.fetchall()
def search_collections(self, collection_name: str = None, presence_name: str = None,
presence_domain: str = None, art_name: str = None):
"""
Search for collections that contain the given parameters.
Search is always fuzzy.
:param collection_name:
:param presence_name:
:param presence_domain:
:param art_name: Search for collections containing art with this title
:result: List[collection_id, collection_name, collection_desc, List[art_id]]
"""
d = {"collection_name": "%"+collection_name+"%", "presence_name": "%"+presence_name+"%",
"presence_domain": "%"+presence_domain+"%", "art_name": "%"+art_name+"%"}
try:
self.db_cursor.execute("SELECT "
"(art_collection.id) as collection_id, "
"(art_collection.name) as collection_name, "
"(art_collection.description) as collection_desc, "
"array_agg(art.id) as art_ID, array_agg(art.title) as art_titles, "
"array_agg(art_to_presence.presence_name) as presence_names, "
"array_agg(art_to_presence.presence_domain) as presence_domains "
"FROM art_collection "
"LEFT OUTER JOIN art_to_art_collection On art_to_art_collection.collection_id = art_collection.id "
"LEFT OUTER JOIN art ON art_to_art_collection.art_id = art.id "
"LEFT OUTER JOIN art_to_presence ON art_to_presence.art_ID = art.id "
"WHERE LOWER(art_collection.name) LIKE LOWER(%(collection_name)s) "
"GROUP BY art_collection.id "
"HAVING LOWER(array_to_string(array_agg(art_to_presence.presence_name), '|', '')) LIKE LOWER(%(presence_name)s) "
"AND LOWER(array_to_string(array_agg(art_to_presence.presence_domain), '|', '')) LIKE LOWER(%(presence_domain)s) "
"AND LOWER(array_to_string(array_agg(art.title), '|', '')) LIKE LOWER(%(art_name)s);",
d)
except psycopg2.DatabaseError as e:
logging.error(f"Encountered database error on search_collections(): {e}")
self.db.rollback()
raise e
return self.db_cursor.fetchall()

@ -32,6 +32,8 @@ class FileReader:
while len(dirs) != 0:
curr_dir = dirs.pop()
curr_full_dir = os.path.join(root, curr_dir)
if not os.path.isdir(curr_full_dir):
continue
l += [os.path.join(curr_dir, f) for f in os.listdir(curr_full_dir) if os.path.isfile(os.path.join(curr_full_dir, f))]
for d in [x for x in os.listdir(curr_full_dir) if os.path.isdir(os.path.join(curr_full_dir, x))]:
dirs.append(os.path.join(curr_dir, d))

@ -41,7 +41,7 @@ class BrowseWindow(ArtnetMainWindow):
self.ui.image_info_button.clicked.connect(self.on_image_info_clicked)
def display_image(self, image_title: str, image_authors: list, full_path: str, relative_path: str, art_ID: int,
link: str, file_name: str, description: str):
link: str, file_name: str, description: str, collections: list):
"""
Display the described image in the GraphicsView
"""

@ -14,7 +14,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_Artist_Mod_Dialog(object):
def setupUi(self, Artist_Mod_Dialog):
Artist_Mod_Dialog.setObjectName("Artist_Mod_Dialog")
Artist_Mod_Dialog.resize(311, 205)
Artist_Mod_Dialog.resize(311, 253)
self.verticalLayout = QtWidgets.QVBoxLayout(Artist_Mod_Dialog)
self.verticalLayout.setObjectName("verticalLayout")
self.dialog_frame = QtWidgets.QFrame(Artist_Mod_Dialog)
@ -49,6 +49,10 @@ class Ui_Artist_Mod_Dialog(object):
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.id_label.sizePolicy().hasHeightForWidth())
self.id_label.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setBold(True)
font.setWeight(75)
self.id_label.setFont(font)
self.id_label.setObjectName("id_label")
self.verticalLayout_5.addWidget(self.id_label)
self.id_layout = QtWidgets.QVBoxLayout()
@ -65,6 +69,10 @@ class Ui_Artist_Mod_Dialog(object):
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.description_label.sizePolicy().hasHeightForWidth())
self.description_label.setSizePolicy(sizePolicy)
font = QtGui.QFont()
font.setBold(True)
font.setWeight(75)
self.description_label.setFont(font)
self.description_label.setObjectName("description_label")
self.verticalLayout_5.addWidget(self.description_label)
self.description_line = QtWidgets.QLineEdit(self.frame)
@ -87,8 +95,8 @@ class Ui_Artist_Mod_Dialog(object):
_translate = QtCore.QCoreApplication.translate
Artist_Mod_Dialog.setWindowTitle(_translate("Artist_Mod_Dialog", "Dialog"))
self.dialog_topic.setText(_translate("Artist_Mod_Dialog", "Artist Topic"))
self.id_label.setText(_translate("Artist_Mod_Dialog", "Artist ID:"))
self.description_label.setText(_translate("Artist_Mod_Dialog", "Artist Description:"))
self.id_label.setText(_translate("Artist_Mod_Dialog", "Artist ID"))
self.description_label.setText(_translate("Artist_Mod_Dialog", "Artist Name"))
if __name__ == "__main__":

@ -7,7 +7,7 @@
<x>0</x>
<y>0</y>
<width>311</width>
<height>205</height>
<height>253</height>
</rect>
</property>
<property name="windowTitle">
@ -63,8 +63,14 @@
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Artist ID:</string>
<string>Artist ID</string>
</property>
</widget>
</item>
@ -90,8 +96,14 @@
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Artist Description:</string>
<string>Artist Name</string>
</property>
</widget>
</item>

@ -0,0 +1,247 @@
import logging
import os.path
from PyQt5 import QtWidgets
from PyQt5.QtCore import Qt, QSize, QModelIndex
from PyQt5.QtGui import QStandardItem, QStandardItemModel, QIcon
from ArtNet.gui.dialogs.image_select_dialog.image_select_dialog import Ui_Dialog
from ArtNet.db.db_adapter import DBAdapter
from ArtNet.singleton_manager import SingletonManager
class ImageSelectDialog(QtWidgets.QDialog):
def __init__(self, db_connection, parent=None):
super().__init__(parent)
self.parent = parent
self.__config = SingletonManager.get_manager().config.data
self.__file_root = self.__config["file_root"]
self.db_connection: DBAdapter = db_connection
self.data: dict = None # data to be returned by this dialog to the parent
self.__tag_search_result = list()
self.__presence_search_result = list()
self.__art_list_dict = dict()
self.curr_selected_tags = list()
self.curr_selected_presences = list()
self.setWindowTitle("Select Art")
self.ui = Ui_Dialog()
self.ui.setupUi(self)
self.ui.search_tag_line.textChanged.connect(self.on_tag_search_changed)
self.ui.search_presence_name_line.textChanged.connect(self.on_presence_search_changed)
self.ui.search_presence_domain_line.textChanged.connect(self.on_presence_search_changed)
self.ui.art_selection_list.hide()
self.ui.art_selection_list.doubleClicked.connect(self.on_art_doubleclicked)
self.on_presence_search_changed("")
self.on_tag_search_changed("")
def set_tag_search_result_list(self, tags: list):
"""
Set the tag search result list given the tags from the search
"""
item_model = QStandardItemModel(self.ui.search_tag_list)
self.__tag_search_result = tags
for tag in tags:
item = QStandardItem(tag)
flags = Qt.ItemIsEnabled
item.setData(Qt.Unchecked, Qt.CheckStateRole)
flags |= Qt.ItemIsUserCheckable
if self.curr_selected_tags is not None and tag in self.curr_selected_tags:
# already selected, implied or aliased tags
item.setCheckState(Qt.Checked)
item.setFlags(flags)
item_model.appendRow(item)
item_model.itemChanged.connect(self.on_tag_search_item_changed)
self.ui.search_tag_list.setModel(item_model)
def refresh_selected_tags_list(self):
"""
Refreshes the selected tags list according to self.curr_selected_tags
"""
item_model = QStandardItemModel(self.ui.selected_tag_list)
for tag in self.curr_selected_tags:
item = QStandardItem(tag)
flags = Qt.ItemIsEnabled
item.setData(Qt.Unchecked, Qt.CheckStateRole)
flags |= Qt.ItemIsUserCheckable
item.setCheckState(Qt.Checked)
item.setFlags(flags)
item_model.appendRow(item)
item_model.itemChanged.connect(self.on_tag_selected_item_changed)
self.ui.selected_tag_list.setModel(item_model)
self.refresh_image_selection()
def refresh_selected_presences_list(self):
"""
Refreshes the selected presences list according to self.curr_selected_presences
"""
item_model = QStandardItemModel(self.ui.selected_presence_list)
for name, domain in self.curr_selected_presences:
item = QStandardItem(f"{name}:{domain}")
flags = Qt.ItemIsEnabled
item.setData(Qt.Unchecked, Qt.CheckStateRole)
flags |= Qt.ItemIsUserCheckable
item.setCheckState(Qt.Checked)
item.setFlags(flags)
item_model.appendRow(item)
item_model.itemChanged.connect(self.on_presence_selected_item_changed)
self.ui.selected_presence_list.setModel(item_model)
self.refresh_image_selection()
def refresh_image_selection(self):
"""
Refreshes the images available for selection by using self.curr_selected_presences and self.curr_selected_tags
"""
logging.debug(f"Refreshing info selection with tags {self.curr_selected_tags} "
f"and presences {self.curr_selected_presences}")
arts = self.db_connection.search_art(tags=self.curr_selected_tags, resolve_tags=True,
presences=self.curr_selected_presences)
#print("Result:", arts)
self.ui.art_placeholder_label.hide()
self.ui.art_selection_list.show()
self.ui.art_selection_list.setViewMode(QtWidgets.QListView.IconMode)
self.__art_list_dict = dict()
item_model = QStandardItemModel(self.ui.art_selection_list)
items = list()
if len(arts) > 100:
arts = arts[:100]
for art in arts:
art_data = self.db_connection.get_art_by_ID(art)
full_path = os.path.join(self.__file_root, art_data["path"])
if not os.path.isfile(full_path):
continue
icon = QIcon()
icon.addFile(full_path)
flags = Qt.ItemIsEnabled
item = QStandardItem(
art_data["title"] if art_data["title"] is not None else os.path.basename(art_data["path"]))
item.setData(icon, role=Qt.DecorationRole)
item.setData(art_data["ID"], role=Qt.UserRole) # allows the item to be identified again
presences = self.db_connection.get_authors_of_art_by_ID(art_data["ID"])
p = ""
for presence in presences:
p += f"{presence[0]}:{presence[1]}|"
p = p[:-1]
item.setData(f"{art_data['title']} \nby \n{p} \n\nDescription:\n{art_data['description']}", role=Qt.ToolTipRole) # tooltip for the item
item.setFlags(flags)
items.append(item)
self.__art_list_dict[art_data["ID"]] = art_data
#print("Adding to last row", items)
item_model.appendColumn(items)
self.ui.art_selection_list.setIconSize(QSize(150, 150))
self.ui.art_selection_list.setModel(item_model)
def set_presence_search_result_list(self, presences: list):
"""
Set the presence search result list as given by presences.
"""
item_model = QStandardItemModel(self.ui.search_presence_list)
self.__presence_search_result = presences
for name, domain in presences:
item = QStandardItem(f"{name}:{domain}")
flags = Qt.ItemIsEnabled
item.setData(Qt.Unchecked, Qt.CheckStateRole)
flags |= Qt.ItemIsUserCheckable
item.setFlags(flags)
item_model.appendRow(item)
item_model.itemChanged.connect(self.on_presence_search_item_changed)
self.ui.search_presence_list.setModel(item_model)
def on_art_doubleclicked(self, index: QModelIndex):
art_id = index.model().itemData(index)[Qt.UserRole]
print(f"Double-clicked: {self.__art_list_dict[art_id]}")
self.data = self.__art_list_dict[art_id]
self.done(QtWidgets.QDialog.Accepted)
def on_tag_search_changed(self, v: str):
result = self.db_connection.search_fuzzy_tag(v, all_if_empty=True)
self.set_tag_search_result_list([r[0] for r in result])
def on_presence_search_changed(self, v: str):
presence_name = self.ui.search_presence_name_line.text()
presence_domain = self.ui.search_presence_domain_line.text()
result = self.db_connection.search_fuzzy_presence(name=presence_name, domain=presence_domain, all_if_empty=True)
presences = list()
for name, domain in result:
presences.append((name, domain))
self.set_presence_search_result_list(presences)
def on_tag_search_item_changed(self, item: QStandardItem):
logging.info(f"Tag search item {item.text()} has changed!")
if item.checkState() == Qt.Checked:
self.curr_selected_tags.append(item.text())
if item.checkState() == Qt.Unchecked:
self.curr_selected_tags.remove(item.text())
self.refresh_selected_tags_list()
def on_tag_selected_item_changed(self, item: QStandardItem):
logging.info(f"Tag select item {item.text()} has changed!")
self.curr_selected_tags.remove(item.text())
self.refresh_selected_tags_list()
self.set_tag_search_result_list(self.__tag_search_result)
def on_presence_search_item_changed(self, item: QStandardItem):
logging.info(f"Presence search item {item.text()} has changed!")
name, domain = item.text().split(":")
if item.checkState() == Qt.Checked:
self.curr_selected_presences.append((name, domain))
elif item.checkState() == Qt.Unchecked:
self.curr_selected_presences.remove((name, domain))
self.refresh_selected_presences_list()
def on_presence_selected_item_changed(self, item: QStandardItem):
logging.info(f"Presence select item {item.text()} has changed!")
name, domain = item.text().split(":")
self.curr_selected_presences.remove((name, domain))
self.set_presence_search_result_list(self.__presence_search_result)
self.refresh_selected_presences_list()
def exec_(self) -> dict:
if super(ImageSelectDialog, self).exec_() == QtWidgets.QDialog.Rejected:
return None
if self.data is None:
self.done(QtWidgets.QDialog.Rejected)
return self.data

@ -20,6 +20,8 @@ from ArtNet.gui.dockers.presence.presence_dock import PresenceDocker
from ArtNet.gui.dialogs.category_modify_dialog.category_mod_dialog import CategoryModDialog
from ArtNet.gui.dialogs.tag_import_dialog.tag_imp_dialog import TagImportDialog
from ArtNet.gui.dialogs.link_input_dialog.link_dialog import LinkInputDialog
from ArtNet.gui.dialogs.collection_select_dialog.collection_sel_dialog import CollectionSelectDialog
from ArtNet.gui.dialogs.collection_modify_dialog.collection_dialog import CollectionModDialog
from ArtNet.web.link_generator import LinkGenerator
@ -28,6 +30,7 @@ class ImporterWindow(ArtnetMainWindow):
UNKNOWN_TITLE = "-Title Unknown-"
UNKNOWN_PRESENCE = "(Not in Database)"
UNKNOWN_LINK = "No Source Available"
NO_COLLECTION = "No Collections"
def __init__(self, main):
super().__init__(main)
@ -52,6 +55,7 @@ class ImporterWindow(ArtnetMainWindow):
self.curr_tags: list = list()
self.curr_implied_tags: list = list()
self.curr_tag_aliases: list = list()
self.curr_collections: list = list()
self.__data_changed: bool = False
@ -154,6 +158,7 @@ class ImporterWindow(ArtnetMainWindow):
"link": self.curr_link,
"md5_hash": self.main.get_md5_of_image(self.curr_art_path),
"description": new_description,
"collections": self.curr_collections,
}
for presence in self.curr_presences:
@ -166,10 +171,19 @@ class ImporterWindow(ArtnetMainWindow):
msg.exec_()
return False
for coll_id, coll_name in self.curr_collections:
msg = QtWidgets.QMessageBox()
msg.setWindowTitle("Could not save collection!")
msg.setInformativeText("The saving and proper selection of collections is not supported as of now!")
msg.setIcon(QtWidgets.QMessageBox.Warning)
msg.exec_()
break
self.main.db_connection.save_image(ID=image_data["ID"], title=image_data["title"],
authors=image_data["authors"], path=image_data["path"],
tags=image_data["tags"], link=image_data["link"],
md5_hash=image_data["md5_hash"], desc=image_data["description"])
md5_hash=image_data["md5_hash"], desc=image_data["description"],
collections=image_data["collections"])
self.set_temporary_status_message("Saved {0} ({1}) to ArtNet DB!"
.format(image_data["title"], image_data["ID"]), 5000)
@ -318,6 +332,30 @@ class ImporterWindow(ArtnetMainWindow):
self.data_changed = True
def get_current_collections(self) -> list:
"""
Get the collections currently associated with the current art
"""
return self.curr_collections
def set_current_collections(self, collections: list):
"""
Set the collections associated with the current art
:param collections: List[Tuple[coll_id, coll_name]]
"""
self.curr_collections = collections
if len(collections) == 0:
self.ui.collections_label.setText(ImporterWindow.NO_COLLECTION)
else:
s = ""
for coll_id, coll_name, coll_desc in collections:
s += "<a href=\"{0}\">{1}</a>".format("", f"#{coll_id}:{coll_name}")
# currently not linking to anything
s += "|"
s = s[:-1]
self.ui.collections_label.setText(s)
self.data_changed = True
def get_categories(self, search: str, all_if_empty: bool = False):
"""
Fuzzy Query for categories in the database.
@ -332,6 +370,35 @@ class ImporterWindow(ArtnetMainWindow):
return self.main.db_connection.search_fuzzy_categories(search)
def create_collection(self, collection_name: str, description: str = None) -> int:
"""
Create a new collection with the given name and description
Returns the assigned ID of the collection
"""
return self.main.db_connection.create_collection(collection_name, description)
def get_collection(self, collection_id: int):
"""
Fetch the collection given by the id. If not present None is returned.
"""
return self.main.db_connection.get_collection(collection_id)
def delete_collection(self, collection_id: int):
"""
Delete the collection given by the id from the database.
"""
self.main.db_connection.delete_collection(collection_id)
def search_collections(self, collection_name: str = None, presence_name: str = None,
presence_domain: str = None, art_name: str = None):
"""
Search for collections that fit the given parameters. All parameters are searched fuzzy.
"""
return self.main.db_connection.search_collections(collection_name=collection_name,
presence_name=presence_name, presence_domain=presence_domain,
art_name=art_name)
def get_image_link_from_label(self) -> str:
"""
Gets the image link from the label if it is a valid link.
@ -432,17 +499,18 @@ class ImporterWindow(ArtnetMainWindow):
:param tags:
:return:
"""
tags.sort()
item_model = QStandardItemModel(self.ui.search_result_list)
for tag in tags:
item = QStandardItem(tag)
flags = Qt.ItemIsEnabled
if tag not in self.curr_imply_tags + self.curr_tag_aliases and tag not in self.curr_tags:
if tag not in self.curr_implied_tags:
# new tag and not implied yet
item.setData(Qt.Unchecked, Qt.CheckStateRole)
flags |= Qt.ItemIsUserCheckable
if self.curr_tags is not None and tag in (self.curr_tags + self.curr_imply_tags + self.curr_tag_aliases):
if self.curr_tags is not None and tag in (self.curr_tags + self.curr_implied_tags + self.curr_tag_aliases):
# already selected, implied or aliased tags
item.setCheckState(Qt.Checked)
item.setFlags(flags)
@ -463,21 +531,37 @@ class ImporterWindow(ArtnetMainWindow):
Also updates the tag implication list if no_implication is False
:param tags:
:param set_checked:
:param no_implication: bool indicating if the implication list should also be updated
:param no_implication: not used
:return:
"""
tags.sort()
self.curr_tags = tags
item_model = QStandardItemModel(self.ui.tag_list)
implied_tags = []
for x in self.curr_tags:
# collect all implied tags into a list
implied_tags += self.main.db_connection.get_all_tag_implications_by_name(x)
self.set_implied_list(implied_tags)
for tag in tags:
item = QStandardItem(tag)
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
item.setData(Qt.Unchecked, Qt.CheckStateRole)
item_model.appendRow(item)
flags = Qt.ItemIsEnabled
if tag not in self.curr_implied_tags:
# new tag and not implied yet
item.setData(Qt.Unchecked, Qt.CheckStateRole)
flags |= Qt.ItemIsUserCheckable
if set_checked:
item.setCheckState(Qt.Checked)
if tag not in self.curr_implied_tags:
item.setCheckState(Qt.Checked)
else:
item.setCheckState(Qt.Unchecked)
item.setFlags(flags)
item_model.appendRow(item)
item_model.itemChanged.connect(self.on_tag_item_changed)
self.ui.tag_list.setModel(item_model)
@ -490,7 +574,7 @@ class ImporterWindow(ArtnetMainWindow):
:param tags:
:return:
"""
self.curr_imply_tags = tags
self.curr_implied_tags = tags
item_model = QStandardItemModel(self.ui.implied_tag_list)
done = []
for tag in tags:
@ -506,7 +590,7 @@ class ImporterWindow(ArtnetMainWindow):
self.data_changed = True
def display_image(self, image_title: str, image_authors: list, full_path: str, relative_path: str, art_ID: int,
link: str, file_name: str, description: str):
link: str, file_name: str, description: str, collections: list):
"""
Display an image in the central widget
:param image_authors:
@ -517,6 +601,7 @@ class ImporterWindow(ArtnetMainWindow):
:param link:
:param file_name:
:param description:
:param collections:
:return:
"""
self.curr_art_id = art_ID
@ -524,6 +609,8 @@ class ImporterWindow(ArtnetMainWindow):
self.curr_image_title = image_title
self.curr_file_name = os.path.basename(full_path)
self.curr_link = link
self.set_current_collections(collections)
self.set_current_presences(image_authors)
file_ending = relative_path.split(".")[-1]
@ -856,6 +943,11 @@ class ImporterWindow(ArtnetMainWindow):
category=tag_data["category"])
self.on_tag_search_change()
implied_tags = [] # refresh implied tags, the edit/addition might have changed smth
for x in self.curr_tags:
# collect all implied tags into a list
implied_tags += self.main.db_connection.get_all_tag_implications_by_name(x)
def on_tag_deletion_clicked(self):
logging.info("Clicked Tag Deletion!")
dialog = TagSelectDialog(self, delete_tag=True)
@ -946,17 +1038,38 @@ class ImporterWindow(ArtnetMainWindow):
def on_tag_search_item_changed(self, item: QStandardItem):
if item.checkState() == Qt.Checked:
self.curr_tags.append(item.text())
aliases = self.main.db_connection.get_tag_aliases_by_name(item.text())
for alias in aliases:
self.curr_tags.append(alias)
implications = self.main.db_connection.get_all_tag_implications_by_name(item.text())
for implication in implications:
self.curr_tags.append(implication)
if item.checkState() == Qt.Unchecked:
if item.text() in self.curr_tags:
self.curr_tags.remove(item.text())
aliases = self.main.db_connection.get_tag_aliases_by_name(item.text())
for alias in aliases:
if alias in self.curr_tags:
self.curr_tags.remove(alias)
implications = self.main.db_connection.get_all_tag_implications_by_name(item.text())
for implication in implications:
self.curr_tags.remove(implication)
else:
return
raise Exception("Something went terribly wrong!")
self.set_tag_list(self.curr_tags)
self.on_tag_search_change()
def on_tag_item_changed(self, item: QStandardItem):
logging.debug("Item {0} has changed!".format(item.text()))
if item.checkState() == Qt.Unchecked:
if item.text() in self.curr_tags:
aliases = self.main.db_connection.get_tag_aliases_by_name(item.text())
if len(aliases) > 0: # tag has aliases, might need to also remove them
for alias in aliases:
self.curr_tags.remove(alias)
self.curr_tags.remove(item.text())
self.set_tag_list(self.curr_tags)
self.on_tag_search_change()
@ -967,7 +1080,7 @@ class ImporterWindow(ArtnetMainWindow):
tags = self.main.db_connection.search_fuzzy_tag(self.ui.tag_search_bar.text(), all_if_empty=True)
result = []
for tag_name, tag_desc, tag_category in tags:
for tag_name, tag_desc, tag_category, tag_id in tags:
result.append(tag_name)
self.set_tag_search_result_list(result)

@ -29,7 +29,7 @@ class ArtnetMainWindow(QtWidgets.QMainWindow):
self.setting_up_data = False
def display_image(self, image_title: str, image_authors: list, full_path: str, relative_path: str, art_ID: int,
link: str, file_name: str, description: str):
link: str, file_name: str, description: str, collections: list):
"""
Display an image in the central widget
:param image_authors:

@ -0,0 +1,13 @@
class SingletonManager:
__instance = None
@staticmethod
def set_instance(instance):
SingletonManager.__instance = instance
@staticmethod
def get_manager():
if SingletonManager.__instance is not None:
return SingletonManager.__instance

2
DB

@ -1 +1 @@
Subproject commit 7a0b0dfc88e46f1ec31842a34c18d408f23f041d
Subproject commit 4d9059b55790d9e758c129fbbf3c706266e999a4

@ -1,13 +1,9 @@
from ArtNet.artnet_manager import ArtNetManager
# TODO fix DB bug
# TODO 1. Open known image
# TODO 2. edit a tag on the current image & save the edit
# TODO 3. attempt to save the current image
# TODO features:
# TODO 2. Add creation/editing/deletion of Topics (Needs Dialogs)
# TODO 3. add changing/adding Topics to Artists (See dockers/topic/artist_topic_docker.py)
# TODO fix bugs:
# TODO 1. import tags on #237 failed to catch all tags
# TODO 2. pressing prev. Unknown from ~#238 returns wrong unknowns! (always #136, #132, #127, then correct)
if __name__ == "__main__":
am = ArtNetManager()

Loading…
Cancel
Save