From 39dbb2926dd3d4257300371c9f5db0bdbdd98634 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 14 Jul 2021 15:57:42 -0700 Subject: [PATCH] add search view --- photoapp/daemon.py | 214 ++++++++++++++++++++++++++++++++++++------ photoapp/utils.py | 12 +++ requirements.txt | 2 +- styles/less/main.less | 23 +++++ templates/page.html | 14 ++- templates/photo.html | 2 +- templates/search.html | 158 +++++++++++++++++++++++++++++++ 7 files changed, 385 insertions(+), 40 deletions(-) create mode 100644 templates/search.html diff --git a/photoapp/daemon.py b/photoapp/daemon.py index 61aa50a..c9b69ef 100644 --- a/photoapp/daemon.py +++ b/photoapp/daemon.py @@ -10,7 +10,7 @@ from photoapp.dbsession import DatabaseSession from photoapp.common import pwhash from photoapp.api import PhotosApi, LibraryManager from photoapp.dbutils import SAEnginePlugin, SATool, db, get_db_engine, date_format -from photoapp.utils import auth, require_auth, photoset_auth_filter, slugify +from photoapp.utils import auth, require_auth, photoset_auth_filter, slugify, cherryparam from photoapp.storage import uri_to_storage from jinja2 import Environment, FileSystemLoader, select_autoescape from sqlalchemy import desc, func, and_, or_ @@ -26,6 +26,9 @@ def validate_password(realm, username, password): class PhotosWeb(object): + """ + Http root of the UI webserver + """ def __init__(self, library, thumbtool, template_dir): self.library = library self.thumbtool = thumbtool @@ -42,6 +45,7 @@ class PhotosWeb(object): self.date = DateView(self) self.tag = TagView(self) self.album = self.tag + self.search = SearchView(self) def render(self, template, **kwargs): """ @@ -53,20 +57,18 @@ class PhotosWeb(object): """ Return a dict containing variables expected to be on every page """ - # all tags / albums with photos visible under the current auth context - tagq = db.query(Tag).join(TagItem).join(PhotoSet) - if not auth(): - tagq = tagq.filter(PhotoSet.status == PhotoStatus.public) - tagq = tagq.filter(Tag.is_album == False).order_by(Tag.name).all() # pragma: manual auth + if auth(): + tagq = db.query(Tag).order_by(Tag.name).all() - albumq = db.query(Tag).join(TagItem).join(PhotoSet) - if not auth(): - albumq = albumq.filter(PhotoSet.status == PhotoStatus.public) - albumq = albumq.filter(Tag.is_album == True).order_by(Tag.name).all() # pragma: manual auth + else: + # for anonymous users, all tags with at least one album / photo marked visible + tagq = db.query(Tag).join(TagItem).join(PhotoSet) + if not auth(): + tagq = tagq.filter(PhotoSet.status == PhotoStatus.public) + tagq = tagq.order_by(Tag.name).all() # pragma: manual auth ret = { "all_tags": tagq, - "all_albums": albumq, "path": cherrypy.request.path_info, "auth": auth(), "PhotoStatus": PhotoStatus @@ -352,25 +354,25 @@ class TagView(object): def index(self, uuid, page=0): page = int(page) pgsize = 100 - if uuid == "untagged": - numphotos = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \ - filter(~PhotoSet.id.in_(db.query(TagItem.set_id))).scalar() - photos = photoset_auth_filter(db.query(PhotoSet)).filter(~PhotoSet.id.in_(db.query(TagItem.set_id))). \ - order_by(PhotoSet.date.desc()). \ - offset(page * pgsize). \ - limit(pgsize).all() - yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page) - else: - tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first() - numphotos = photoset_auth_filter(db.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \ - filter(Tag.id == tag.id).scalar() - photos = photoset_auth_filter(db.query(PhotoSet)).join(TagItem).join(Tag). \ - filter(Tag.id == tag.id). \ - order_by(PhotoSet.date.desc()). \ - offset(page * pgsize). \ - limit(pgsize).all() - yield self.master.render("album.html", tag=tag, images=photos, - total_items=numphotos, pgsize=pgsize, page=page) + # if uuid == "untagged": + # numphotos = photoset_auth_filter(db.query(func.count(PhotoSet.id))). \ + # filter(~PhotoSet.id.in_(db.query(TagItem.set_id))).scalar() + # photos = photoset_auth_filter(db.query(PhotoSet)).filter(~PhotoSet.id.in_(db.query(TagItem.set_id))). \ + # order_by(PhotoSet.date.desc()). \ + # offset(page * pgsize). \ + # limit(pgsize).all() + # yield self.master.render("untagged.html", images=photos, total_items=numphotos, pgsize=pgsize, page=page) + # else: + tag = db.query(Tag).filter(or_(Tag.uuid == uuid, Tag.slug == uuid)).first() + numphotos = photoset_auth_filter(db.query(func.count(Tag.id)).join(TagItem).join(PhotoSet)). \ + filter(Tag.id == tag.id).scalar() + photos = photoset_auth_filter(db.query(PhotoSet)).join(TagItem).join(Tag). \ + filter(Tag.id == tag.id). \ + order_by(PhotoSet.date.desc()). \ + offset(page * pgsize). \ + limit(pgsize).all() + yield self.master.render("album.html", tag=tag, images=photos, + total_items=numphotos, pgsize=pgsize, page=page) @cherrypy.expose @require_auth @@ -418,6 +420,158 @@ class TagView(object): yield self.master.render("tag_edit.html", tag=tag) +FMT_TIME_SEARCH = "%Y/%m/%d %H.%M.%S" + + +class SearchView(object): + """ + Search & operations view. + Search -> View targets -> Apply actions + """ + def __init__(self, master): + self.master = master + + @cherrypy.expose + def index( + self, + page=0, + pgsize=100, + include_tags=None, + exclude_tags=None, + untagged=False, + before=None, + after=None, + set_before=None, + set_after=None, + keywords_title=None, + keywords_description=None, + operation=None, + add_tag=None, + remove_tag=None, + ): + page = int(page) + pgsize = int(pgsize) + + before = set_before or before + after = set_after or after + before = datetime.strptime(before, FMT_TIME_SEARCH) if before else None + after = datetime.strptime(after, FMT_TIME_SEARCH) if after else None + + include_tags = cherryparam(include_tags) + exclude_tags = cherryparam(exclude_tags) + add_tag = cherryparam(add_tag) + remove_tag = cherryparam(remove_tag) + + untagged = untagged in frozenset([True, 1, "1", "yes"]) + + include_tag_ids = [] + if include_tags: + include_tag_ids = [i[0] for i in db.query(Tag.id).filter(Tag.uuid.in_(include_tags)).all()] + + exclude_tag_ids = [] + if exclude_tags: + exclude_tag_ids = [i[0] for i in db.query(Tag.id).filter(Tag.uuid.in_(exclude_tags)).all()] + + # build image search query + def build_query(base): + base = photoset_auth_filter(base) + + if before: + base = base \ + .filter(PhotoSet.date <= before) + + if after: + base = base \ + .filter(PhotoSet.date >= after) + + if include_tag_ids or exclude_tag_ids: + base = base \ + .join(TagItem) \ + .join(Tag) + + if include_tag_ids: + base = base.filter(Tag.id.in_(include_tag_ids)) + + if exclude_tag_ids: + base = base.filter(~PhotoSet.id.in_( + db.query(PhotoSet.id) + .join(TagItem) + .join(Tag) + .filter(Tag.id.in_(exclude_tag_ids)))) + + if untagged: + base = base \ + .filter(~PhotoSet.id.in_(db.query(TagItem.set_id))) + + if keywords_title: + base = base \ + .filter(PhotoSet.title.like("%{}%".format(keywords_title))) + + if keywords_description: + base = base \ + .filter(PhotoSet.description.like("%{}%".format(keywords_description))) + + base = base \ + .order_by(PhotoSet.date.desc()) + + return base + + if add_tag: + # this is extremely inefficient but i can't be arsed stepping outside of sqlalchemy + # no replace into :-( + tag_ids = set([r[0] for r in db.query(Tag.id).filter(Tag.uuid.in_(add_tag)).all()]) + + if tag_ids: + for photo in build_query(db.query(PhotoSet)).all(): + # get tag ids for all tags on the photo + has_tagids = set([ + r[0] for r in db.query(Tag.id).join(TagItem).filter(TagItem.set_id == photo.id).all() + ]) + # calculate which tags the image misses + new_tags = tag_ids - has_tagids + + # add each new tag to the image + for new_tagid in new_tags: + db.add(TagItem(tag_id=new_tagid, set_id=photo.id)) + db.commit() + + if remove_tag: + # this is not as bad as add_tag but could be better + tag_ids = [r[0] for r in db.query(Tag.id).filter(Tag.uuid.in_(remove_tag)).all()] + photo_ids = [r[0] for r in build_query(db.query(PhotoSet.id)).all()] + + if tag_ids and photo_ids: + db.query(TagItem) \ + .filter(TagItem.tag_id.in_(tag_ids)) \ + .filter(TagItem.set_id.in_(photo_ids)) \ + .delete(synchronize_session=False) + db.commit() + + # consequence of synchronize_session=False above + # or is it Fine since we don't re-use any orm-mapped objects from above? + db.expire_all() + + total_sets = build_query(db.query(func.count(PhotoSet.id))).scalar() + images = build_query(db.query(PhotoSet)).offset(pgsize * page).limit(pgsize).all() + + yield self.master.render( + "search.html", + include_tags=include_tags, + exclude_tags=exclude_tags, + selecting_untagged=untagged, + images=[i for i in images], + total_sets=total_sets, + page=page, + pgsize=int(pgsize), + operation=operation, + before=before.strftime(FMT_TIME_SEARCH) if before else "", + after=after.strftime(FMT_TIME_SEARCH) if after else "", + keywords_title=keywords_title or "", + keywords_description=keywords_description or "", + now=datetime.now().strftime(FMT_TIME_SEARCH), + ) + + def main(): import argparse import signal diff --git a/photoapp/utils.py b/photoapp/utils.py index f71ee70..39ce12d 100644 --- a/photoapp/utils.py +++ b/photoapp/utils.py @@ -62,3 +62,15 @@ def photoset_auth_filter(query): def slugify(words): return ''.join(letter for letter in '-'.join(words.lower().split()) if ('a' <= letter <= 'z') or ('0' <= letter <= '9') or letter == '-') + + +def cherryparam(v, type_=str): + """ + Cherrypy handles duplicate or list field names in post/get/body parameters by setting the parameter value to + a list of strings. However, if there is just one entry the parameter value is a string. Third, if the + field isn't provided the value is None. This function always returns a list of values. + """ + v = v or [] + if type(v) == type_: + v = [v] # one entry + return v diff --git a/requirements.txt b/requirements.txt index d884761..da32597 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ Jinja2==2.11.2 jmespath==0.10.0 MarkupSafe==1.1.1 more-itertools==8.5.0 -Pillow==7.2.0 +Pillow==8.3.1 portend==2.6 pycparser==2.20 PyMySQL==0.10.0 diff --git a/styles/less/main.less b/styles/less/main.less index 726d763..0290ff5 100644 --- a/styles/less/main.less +++ b/styles/less/main.less @@ -286,3 +286,26 @@ ul.pager { font-weight: bold; } } + +.search-result { + float: left; + position: relative; + margin: 2px; + width: 100px; + height: 100px; + + .inner { + display: none; + position: absolute; + top: 75px; + left: 0px; + width: 200px; + background-color: #fff; + z-index: 1; + border: 1px solid #000; + padding: 2px; + } + &:hover .inner { + display: block; + } +} diff --git a/templates/page.html b/templates/page.html index 61cc440..e4bbc8a 100644 --- a/templates/page.html +++ b/templates/page.html @@ -14,20 +14,18 @@
diff --git a/templates/photo.html b/templates/photo.html index b430cee..6e6febe 100644 --- a/templates/photo.html +++ b/templates/photo.html @@ -94,7 +94,7 @@
-

Tags{% if auth %} add{% endif %}

+

Tags{% if auth %} edit{% endif %}