add search view

This commit is contained in:
dave 2021-07-14 15:57:42 -07:00
parent 6c4c1c609a
commit 39dbb2926d
7 changed files with 385 additions and 40 deletions

View File

@ -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
if auth():
tagq = db.query(Tag).order_by(Tag.name).all()
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.filter(Tag.is_album == False).order_by(Tag.name).all() # pragma: manual auth
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
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,15 +354,15 @@ 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:
# 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()
@ -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

View File

@ -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

View File

@ -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

View File

@ -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;
}
}

View File

@ -14,20 +14,18 @@
<div class="pure-menu">
<ul class="pure-menu-list">
<li class="pure-menu-item"><a href="/" class="pure-menu-link">All photos</a></li>
<li class="pure-menu-item"><a href="/albums" class="pure-menu-link">Albums</a></li>
<li class="pure-menu-item"><a href="/search" class="pure-menu-link">Search</a></li>
<li class="pure-menu-item"><a href="/date" class="pure-menu-link">Dates</a></li>
<li class="pure-menu-item"><a href="/stats" class="pure-menu-link">Stats</a></li>
<li class="pure-menu-item"><a href="/map" class="pure-menu-link">Map</a></li>
<li class="pure-menu-item"><a href="/tag/untagged" class="pure-menu-link">Untagged</a></li>
<li class="pure-menu-item"><a href="/admin/trash" class="pure-menu-link">Trash</a></li>
<li class="pure-menu-item"><a href="/stats" class="pure-menu-link">Stats</a></li>
<li class="pure-menu-heading">Albums</li>
{% for tag in all_albums %}
{% for tag in all_tags %}{% if tag.is_album %}
<li class="pure-menu-item"><a href="/album/{{ tag.slug }}" class="pure-menu-link"><span class="tag-icon tag-icon-mod-6-{{ tag.id % 6 }}"></span>{{ tag.name }}</a></li>
{% endfor %}
{% endif %}{% endfor %}
<li class="pure-menu-heading">Tags</li>
{% for tag in all_tags %}
{% for tag in all_tags %}{% if not tag.is_album %}
<li class="pure-menu-item"><a href="/tag/{{ tag.slug }}" class="pure-menu-link"><span class="tag-icon tag-icon-mod-6-{{ tag.id % 6 }}"></span>{{ tag.name }}</a></li>
{% endfor %}
{% endif %}{% endfor %}
</ul>
</div>
<div class="user-status">

View File

@ -94,7 +94,7 @@
</ul>
</div>
<div class="photo-tags">
<h2>Tags{% if auth %} <a href="/create_tags?uuid={{ image.uuid }}">add</a>{% endif %}</h2>
<h2>Tags{% if auth %} <a href="/create_tags?uuid={{ image.uuid }}">edit</a>{% endif %}</h2>
<ul class="tags-picker">
{% for tagi in image.tags %}
<li>

158
templates/search.html Normal file
View File

@ -0,0 +1,158 @@
{% extends "page.html" %}
{% block title %}Search & Bulk Operations{% endblock %}
{% block subtitle %}Matched Images: {{ total_sets }} <a href="/search">reset</a>{% endblock %}
{% block buttons %}{% endblock %}
{% block body %}
<form method="post" action="/search">
<div class="pure-g">
<div class="pure-u-1-5 ">
<p>Include tags</p>
<select multiple name="include_tags" style="height: 200px;width:100%;">
{% for tag in all_tags %}
<option value="{{ tag.uuid }}"{% if tag.uuid in include_tags %} selected{% endif %}>{{ tag.title }}</option>
{% endfor %}
</select>
</div>
<div class="pure-u-1-5">
<p>Exclude tags</p>
<select multiple name="exclude_tags" style="height:200px;width:100%;">
{% for tag in all_tags %}
<option value="{{ tag.uuid }}"{% if tag.uuid in exclude_tags %} selected{% endif %}>{{ tag.title }}</option>
{% endfor %}
</select>
</div>
<div class="pure-u-1-5">
<p>Date range</p>
<div>
<label>
Before:<br />
<input type="text" name="before" value="{{ before }}" />
</label>
</div>
<div>
<label>
After:<br />
<input type="text" name="after" value="{{ after }}" />
</label>
</div>
<div>
Now: <pre style="margin:0">{{ now }}</pre>
</div>
</div>
<div class="pure-u-1-5">
<p>Keywords</p>
<div>
<label>
Title:<br />
<input type="text" name="keywords_title" value="{{ keywords_title }}" />
</label>
</div>
<div>
<label>
Description:<br />
<input type="text" name="keywords_description" value="{{ keywords_description }}" />
</label>
</div>
</div>
<div class="pure-u-1-5">
<p>Options</p>
<label><input type="checkbox" name="untagged" value="1"{% if selecting_untagged %} checked="checked"{% endif %} /> Untagged</label>
</div>
</div>
<input type="submit" value="Search">
<hr>
<h2>Preview</h2>
<div>
{% for image in images %}
<div class="search-result">
<a href="/photo/{{ image.uuid }}">
<img src="/thumb/set/small/{{ image.uuid }}.jpg" style="max-width: 100px"/>
</a>
<div class="inner">
<button name="set_after" value="{{ image.date.strftime("%Y/%m/%d %H.%M.%S") }}" type="submit">After</button>
<button name="set_before" value="{{ image.date.strftime("%Y/%m/%d %H.%M.%S") }}" type="submit">Before</button>
{{ image.date }}
</div>
</div>
{% endfor %}
<br clear="all" />
</div>
{% set total_pages = (total_sets/pgsize)|ceil %}
<div class="pager">
<h6>Page</h6>
{% if page > 0 %}
<div class="nav-prev">
<button name="page" value="{{ page - 1 }}" type="submit">Previous</button>
</div>
{% endif %}
<div class="pages">
<ul class="pager">
{% for pgnum in range(0, total_pages) %}
<li{% if pgnum == page %} class="current"{% endif %}>
<input type="submit" name="page" value="{{ pgnum }}">
</li>
{% endfor %}
</ul>
</div>
{% if page + 1 < total_pages %}
<div class="nav-next">
<button name="page" value="{{ page + 1 }}" type="submit">Next</button>
</div>
{% endif %}
<label for="pgsize">Page size: </label>
<select name="pgsize">
<option value="25"{% if pgsize == 25 %} selected="selected"{% endif %}>25</option>
<option value="100"{% if pgsize == 100 %} selected="selected"{% endif %}>100</option>
<option value="250"{% if pgsize == 250 %} selected="selected"{% endif %}>250</option>
<option value="1000"{% if pgsize == 1000 %} selected="selected"{% endif %}>1000</option>
</select>
</div>
<hr>
<h2>Operation</h2>
<select name="operation">
<option value=""></option>
<option value="add_tags"{% if operation == "add_tags" %} selected{% endif %}>Add Tags</option>
<option value="remove_tags"{% if operation == "remove_tags" %} selected{% endif %}>Remove Tags</option>
</select>
<input type="submit" value="Go">
<hr />
{% if operation == "add_tags" %}
<h2>Add Tag</h2>
<div>
<ul class="tags-picker">
{% for tag in all_tags %}
<li>
<button name="add_tag" value="{{ tag.uuid }}" type="submit">{{ tag.name }}</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if operation == "remove_tags" %}
<h2>Remove Tag</h2>
<div>
<ul class="tags-picker">
{% for tag in all_tags %}
<li>
<button name="remove_tag" value="{{ tag.uuid }}" type="submit">{{ tag.name }}</button>
</li>
{% endfor %}
</ul>
{% endif %}
</form>
{% endblock %}