diff --git a/photoapp/api.py b/photoapp/api.py index f723116..fd456b1 100644 --- a/photoapp/api.py +++ b/photoapp/api.py @@ -2,8 +2,8 @@ import os import cherrypy import json from datetime import datetime -from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, known_extensions, known_mimes, \ - genuuid, generate_storage_path +from photoapp.types import Photo, PhotoSet, Tag, TagItem, PhotoStatus, User, JobTargetType, known_extensions, \ + known_mimes, genuuid, generate_storage_path from photoapp.utils import copysha, get_extension, slugify from photoapp.image import special_magic_fobj from photoapp.storage import StorageAdapter @@ -287,44 +287,74 @@ class PhotosApiV1PhotoTags(object): return {} +@cherrypy.expose +@cherrypy.tools.json_out() @cherrypy.popargs("uuid") class JobsApiV1(object): def __init__(self, library): self.library = library - @cherrypy.expose - @cherrypy.tools.allow(methods=["POST", "GET"]) - @cherrypy.tools.json_out() - def index( - self, - uuid=None, # when fetching an existing job, the job's UUID - - ): + def GET(self, uuid=None): """ show the list of jobs or the specified job - - or, if we're POST/PUTing, create a new job - POSTing without the job_args field will tell you the list of job args you need to submit - POSTing with job_args will actually create the job """ - # if cherrypy.request.method == "POST": # creating job - # print() - # print('body', cherrypy.request.body.read()) - # return 'handle post' + return "jobs get " + str(uuid) - # return 'job: ' + str(uuid) + def POST(self): + """ + create a new job or update an existing one - # return cherrypy.dispatch.Dispatcher.call(self.other) + JSON body should look like: - request, response = cherrypy.request, cherrypy.response - request.dispatch = cherrypy.dispatch.Dispatcher() - cherrypy._cptools.HandlerTool.callable_method_handler(self.other) + { + "name": "blah", + "targets": [ + { + "type": "photo", + "ids": [ + 1, + 2, + 3 + ] + } + ] + } - print("response:", response) + POSTing without the job_args field will tell you the list of job args you need to submit + POSTing with job_args will actually create the job + """ - return "lol" + body = json.loads(cherrypy.request.body.read().decode()) # TODO max size - @cherrypy.expose - def other(self): - print("in other") - yield "other result" + # translate target UUIDs to IDs + target_ids = [] + for target_number, target in enumerate(body["targets"]): + typ = JobTargetType[target["type"]] + + if typ == JobTargetType.photo: + query = db.query(Photo.id).filter(Photo.uuid.in_(target["uuids"])) + elif typ == JobTargetType.photoset: + query = db.query(PhotoSet.id).filter(PhotoSet.uuid.in_(target["uuids"])) + elif typ == JobTargetType.tag: + query = db.query(Tag.id).filter(Tag.name.in_(target["uuids"])) + else: + raise Exception() + + ids = [r[0] for r in query.all()] + + if len(target["uuids"]) != len(ids): # TODO would be nice if we would say exactly which + raise cherrypy.HTTPError(400, "missing or duplicate UUIDs in target {}".format(target_number)) + + target_ids.append(dict( + type=JobTargetType[target["type"]], + targets=[r[0] for r in query.all()] + )) + + # output the job's UUID + return cherrypy.engine.publish("job-create", body["name"], target_ids)[0] + + def DELETE(self, uuid): + """ + delete an existing job + """ + return "jobs delete" diff --git a/photoapp/daemon.py b/photoapp/daemon.py index 8aa1c66..6360164 100644 --- a/photoapp/daemon.py +++ b/photoapp/daemon.py @@ -18,6 +18,7 @@ from photoapp.dbutils import SAEnginePlugin, SATool, db, get_db_engine, date_for from photoapp.utils import auth, require_auth, photoset_auth_filter, slugify, cherryparam, number_format from photoapp.storage import uri_to_storage from photoapp.webutils import validate_password, serve_thumbnail_placeholder +from photoapp.jobs import JobsClient, JobSubscriber from jinja2 import Environment, FileSystemLoader, select_autoescape from sqlalchemy import desc, asc, func, and_, or_ @@ -632,6 +633,8 @@ def setup_webapp(database_url, library_url, cache_url, thumb_service_url, debug= library_storage = uri_to_storage(library_url) library_manager = LibraryManager(library_storage) thumbnail_tool = ThumbGenerator(library_manager, uri_to_storage(cache_url), thumb_service_url) + jobs_client = JobsClient(engine) + JobSubscriber(jobs_client) # Setup and mount web ui tpl_dir = os.path.join(APPROOT, "templates") if not debug else "templates" diff --git a/photoapp/dbutils.py b/photoapp/dbutils.py index c574864..8c9f48c 100644 --- a/photoapp/dbutils.py +++ b/photoapp/dbutils.py @@ -1,11 +1,14 @@ +from contextlib import closing import sqlalchemy import cherrypy from cherrypy.process import plugins from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.pool import NullPool from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm.session import Session from sqlalchemy import func + Base = declarative_base() @@ -115,3 +118,20 @@ class SATool(cherrypy.Tool): raise finally: self.session.remove() + + +def cursorwrap(func): + """ + Provides a cursor to the wrapped method as the first arg. This assumes that the wrapped function belongs to an + object because the cursor is sourced from the object's session attribute which is assumed to be a + sessionmaker callable. + """ + def wrapped(*args, **kwargs): + self = args[0] + # passthru if someone already passed a session + if len(args) >= 2 and isinstance(args[1], (Session, sqlalchemy.orm.scoping.scoped_session, DbAlias)): + return func(*args, **kwargs) + else: + with closing(self.session()) as c: + return func(self, c, *args[1:], **kwargs) + return wrapped diff --git a/photoapp/jobs.py b/photoapp/jobs.py index ab69fe2..46a3b4a 100644 --- a/photoapp/jobs.py +++ b/photoapp/jobs.py @@ -1,4 +1,45 @@ +import cherrypy +from photoapp.dbutils import create_db_sessionmaker, cursorwrap +from photoapp.types import Job, JobTargetType, JobTarget -class JobsDAO(object): - pass \ No newline at end of file +class JobSubscriber(object): + """ + adapter between cherrypy bus and JobsClient + """ + def __init__(self, client): + self.client = client + cherrypy.engine.subscribe("job-create", self.client.create_job) # TODO make "job-create" a const somewhere? + + +class JobsClient(object): + def __init__(self, dbengine): + self.engine = dbengine + self.session = create_db_sessionmaker(self.engine) + + @cursorwrap + def create_job(self, db, name, targets): + """ + targets: list of dict( + type=JobTargetType.TYPE, + targets=[1, 2, 3] + ) + """ + job_targets = [] + + for target in targets: + for target_id in target["targets"]: + job_targets.append( + JobTarget(target_type=target["type"], + target=target_id) + ) + + j = Job( + job_name=name, + targets=job_targets, + ) + + db.add(j) + db.commit() + + return j.uuid diff --git a/photoapp/types.py b/photoapp/types.py index 293776b..7359850 100644 --- a/photoapp/types.py +++ b/photoapp/types.py @@ -237,7 +237,7 @@ class Job(Base): uuid = Column(Unicode(length=36), unique=True, nullable=False, default=lambda: str(uuid.uuid4())) created = Column(DateTime, nullable=False, default=lambda: datetime.now()) - job_name = Column(String(length=64), nullable=False) + job_name = Column(String(length=64), unique=True, nullable=False) status = Column(Enum(JobStatus), nullable=False, default=JobStatus.paused) targets = relationship("JobTarget", back_populates="job")