app.py
Python script, ASCII text executable
1import json 2import os 3import mimetypes 4import flask 5import ruamel.yaml as yaml 6import sqlalchemy.dialects.postgresql 7from matplotlib.image import thumbnail 8 9import config 10import markdown 11 12from datetime import datetime 13from os import path 14from flask_sqlalchemy import SQLAlchemy 15from flask_bcrypt import Bcrypt 16from flask_migrate import Migrate, current 17from urllib.parse import urlencode 18from PIL import Image 19 20app = flask.Flask(__name__) 21bcrypt = Bcrypt(app) 22 23app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 24app.config["SECRET_KEY"] = config.DB_PASSWORD 25 26db = SQLAlchemy(app) 27migrate = Migrate(app, db) 28 29 30@app.template_filter("split") 31def split(value, separator=None, maxsplit=-1): 32return value.split(separator, maxsplit) 33 34 35@app.template_filter("median") 36def median(value): 37value = list(value) # prevent generators 38return sorted(value)[len(value) // 2] 39 40 41@app.template_filter("set") 42def set_filter(value): 43return set(value) 44 45 46@app.template_global() 47def modify_query(**new_values): 48args = flask.request.args.copy() 49for key, value in new_values.items(): 50args[key] = value 51 52return f"{flask.request.path}?{urlencode(args)}" 53 54 55@app.context_processor 56def default_variables(): 57return { 58"current_user": db.session.get(User, flask.session.get("username")), 59"site_name": config.SITE_NAME, 60} 61 62 63with app.app_context(): 64class User(db.Model): 65username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 66password_hashed = db.Column(db.String(60), nullable=False) 67admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 68pictures = db.relationship("PictureResource", back_populates="author") 69joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 70galleries = db.relationship("Gallery", back_populates="owner") 71galleries_joined = db.relationship("UserInGallery", back_populates="user") 72ratings = db.relationship("PictureRating", back_populates="user") 73 74def __init__(self, username, password): 75self.username = username 76self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 77 78@property 79def formatted_name(self): 80if self.admin: 81return self.username + "*" 82return self.username 83 84 85class Licence(db.Model): 86id = db.Column(db.String(64), primary_key=True) # SPDX identifier 87title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 88description = db.Column(db.UnicodeText, 89nullable=False) # brief description of its permissions and restrictions 90info_url = db.Column(db.String(1024), 91nullable=False) # the URL to a page with general information about the licence 92url = db.Column(db.String(1024), 93nullable=True) # the URL to a page with the full text of the licence and more information 94pictures = db.relationship("PictureLicence", back_populates="licence") 95free = db.Column(db.Boolean, nullable=False, 96default=False) # whether the licence is free or not 97logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 98pinned = db.Column(db.Boolean, nullable=False, 99default=False) # whether the licence should be shown at the top of the list 100 101def __init__(self, id, title, description, info_url, url, free, logo_url=None, 102pinned=False): 103self.id = id 104self.title = title 105self.description = description 106self.info_url = info_url 107self.url = url 108self.free = free 109self.logo_url = logo_url 110self.pinned = pinned 111 112 113class PictureLicence(db.Model): 114id = db.Column(db.Integer, primary_key=True, autoincrement=True) 115 116resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 117licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 118 119resource = db.relationship("PictureResource", back_populates="licences") 120licence = db.relationship("Licence", back_populates="pictures") 121 122def __init__(self, resource, licence): 123self.resource = resource 124self.licence = licence 125 126 127class Resource(db.Model): 128__abstract__ = True 129 130id = db.Column(db.Integer, primary_key=True, autoincrement=True) 131title = db.Column(db.UnicodeText, nullable=False) 132description = db.Column(db.UnicodeText, nullable=False) 133timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 134origin_url = db.Column(db.String(2048), 135nullable=True) # should be left empty if it's original or the source is unknown but public domain 136 137 138class PictureNature(db.Model): 139# Examples: 140# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 141# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 142# "screen-photo", "pattern", "collage", "ai", and so on 143id = db.Column(db.String(64), primary_key=True) 144description = db.Column(db.UnicodeText, nullable=False) 145resources = db.relationship("PictureResource", back_populates="nature") 146 147def __init__(self, id, description): 148self.id = id 149self.description = description 150 151 152class PictureObjectInheritance(db.Model): 153parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 154primary_key=True) 155child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 156primary_key=True) 157 158parent = db.relationship("PictureObject", foreign_keys=[parent_id], 159back_populates="child_links") 160child = db.relationship("PictureObject", foreign_keys=[child_id], 161back_populates="parent_links") 162 163def __init__(self, parent, child): 164self.parent = parent 165self.child = child 166 167 168class PictureObject(db.Model): 169id = db.Column(db.String(64), primary_key=True) 170description = db.Column(db.UnicodeText, nullable=False) 171 172child_links = db.relationship("PictureObjectInheritance", 173foreign_keys=[PictureObjectInheritance.parent_id], 174back_populates="parent") 175parent_links = db.relationship("PictureObjectInheritance", 176foreign_keys=[PictureObjectInheritance.child_id], 177back_populates="child") 178 179def __init__(self, id, description, parents): 180self.id = id 181self.description = description 182if parents: 183for parent in parents: 184db.session.add(PictureObjectInheritance(parent, self)) 185 186 187class PictureRegion(db.Model): 188# This is for picture region annotations 189id = db.Column(db.Integer, primary_key=True, autoincrement=True) 190json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 191 192resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 193nullable=False) 194object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 195 196resource = db.relationship("PictureResource", backref="regions") 197object = db.relationship("PictureObject", backref="regions") 198 199def __init__(self, json, resource, object): 200self.json = json 201self.resource = resource 202self.object = object 203 204 205class PictureResource(Resource): 206# This is only for bitmap pictures. Vectors will be stored under a different model 207# File name is the ID in the picture directory under data, without an extension 208file_format = db.Column(db.String(64), nullable=False) # MIME type 209width = db.Column(db.Integer, nullable=False) 210height = db.Column(db.Integer, nullable=False) 211nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 212author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 213author = db.relationship("User", back_populates="pictures") 214 215nature = db.relationship("PictureNature", back_populates="resources") 216 217replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 218replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 219nullable=True) 220 221replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 222foreign_keys=[replaces_id], back_populates="replaced_by", 223post_update=True) 224replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 225foreign_keys=[replaced_by_id], post_update=True) 226 227copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 228nullable=True) 229copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 230backref="copies", foreign_keys=[copied_from_id]) 231 232licences = db.relationship("PictureLicence", back_populates="resource") 233galleries = db.relationship("PictureInGallery", back_populates="resource") 234ratings = db.relationship("PictureRating", back_populates="resource") 235 236def __init__(self, title, author, description, origin_url, licence_ids, mime, 237nature=None): 238self.title = title 239self.author = author 240self.description = description 241self.origin_url = origin_url 242self.file_format = mime 243self.width = self.height = 0 244self.nature = nature 245db.session.add(self) 246db.session.commit() 247for licence_id in licence_ids: 248joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 249db.session.add(joiner) 250 251def put_annotations(self, json): 252# Delete all previous annotations 253db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 254 255for region in json: 256object_id = region["object"] 257picture_object = db.session.get(PictureObject, object_id) 258 259region_data = { 260"type": region["type"], 261"shape": region["shape"], 262} 263 264region_row = PictureRegion(region_data, self, picture_object) 265db.session.add(region_row) 266 267@property 268def average_rating(self): 269if not self.ratings: 270return None 271return db.session.query(db.func.avg(PictureRating.rating)).filter_by(resource=self).scalar() 272 273@property 274def rating_totals(self): 275all_ratings = db.session.query(PictureRating.rating).filter_by(resource=self) 276return {rating: all_ratings.filter_by(rating=rating).count() for rating in range(1, 6)} 277 278@property 279def stars(self): 280if not self.ratings: 281return 0 282average = self.average_rating 283whole_stars = int(average) 284partial_star = average - whole_stars 285 286return [100] * whole_stars + [int(partial_star * 100)] + [0] * (4 - whole_stars) 287 288 289class PictureInGallery(db.Model): 290id = db.Column(db.Integer, primary_key=True, autoincrement=True) 291resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 292nullable=False) 293gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 294 295resource = db.relationship("PictureResource") 296gallery = db.relationship("Gallery") 297 298def __init__(self, resource, gallery): 299self.resource = resource 300self.gallery = gallery 301 302 303class UserInGallery(db.Model): 304id = db.Column(db.Integer, primary_key=True, autoincrement=True) 305username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 306gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 307 308user = db.relationship("User") 309gallery = db.relationship("Gallery") 310 311def __init__(self, user, gallery): 312self.user = user 313self.gallery = gallery 314 315 316class Gallery(db.Model): 317id = db.Column(db.Integer, primary_key=True, autoincrement=True) 318title = db.Column(db.UnicodeText, nullable=False) 319description = db.Column(db.UnicodeText, nullable=False) 320pictures = db.relationship("PictureInGallery", back_populates="gallery") 321owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 322owner = db.relationship("User", back_populates="galleries") 323users = db.relationship("UserInGallery", back_populates="gallery") 324 325def __init__(self, title, description, owner): 326self.title = title 327self.description = description 328self.owner = owner 329 330 331class PictureRating(db.Model): 332id = db.Column(db.Integer, primary_key=True, autoincrement=True) 333resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 334username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 335rating = db.Column(db.Integer, db.CheckConstraint("rating >= 1 AND rating <= 5"), 336nullable=False) 337 338resource = db.relationship("PictureResource", back_populates="ratings") 339user = db.relationship("User", back_populates="ratings") 340 341def __init__(self, resource, user, rating): 342self.resource = resource 343self.user = user 344self.rating = rating 345 346 347@app.route("/") 348def index(): 349return flask.render_template( 350"home.html", 351random_resources=PictureResource.query.filter_by(replaced_by=None).order_by(db.func.random()).limit(10).all(), 352recent_resources=PictureResource.query.filter_by(replaced_by=None).order_by(PictureResource.timestamp.desc()).limit(10).all(), 353recent_unannotated_resources=PictureResource.query.filter_by(replaced_by=None).filter(~PictureResource.regions.any()).order_by(PictureResource.timestamp.desc()).limit(10).all(), 354) 355 356 357@app.route("/info/") 358def usage_guide(): 359with open("help/usage.md") as f: 360return flask.render_template("help.html", content=markdown.markdown2html(f.read())) 361 362 363@app.route("/accounts/") 364def accounts(): 365return flask.render_template("login.html") 366 367 368@app.route("/login", methods=["POST"]) 369def login(): 370username = flask.request.form["username"] 371password = flask.request.form["password"] 372 373user = db.session.get(User, username) 374 375if user is None: 376flask.flash("This username is not registered.") 377return flask.redirect("/accounts") 378 379if not bcrypt.check_password_hash(user.password_hashed, password): 380flask.flash("Incorrect password.") 381return flask.redirect("/accounts") 382 383flask.flash("You have been logged in.") 384 385flask.session["username"] = username 386return flask.redirect("/") 387 388 389@app.route("/logout") 390def logout(): 391flask.session.pop("username", None) 392flask.flash("You have been logged out.") 393return flask.redirect("/") 394 395 396@app.route("/signup", methods=["POST"]) 397def signup(): 398username = flask.request.form["username"] 399password = flask.request.form["password"] 400 401if db.session.get(User, username) is not None: 402flask.flash("This username is already taken.") 403return flask.redirect("/accounts") 404 405if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 406flask.flash( 407"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 408return flask.redirect("/accounts") 409 410if len(username) < 3 or len(username) > 32: 411flask.flash("Usernames must be between 3 and 32 characters long.") 412return flask.redirect("/accounts") 413 414if len(password) < 6: 415flask.flash("Passwords must be at least 6 characters long.") 416return flask.redirect("/accounts") 417 418user = User(username, password) 419db.session.add(user) 420db.session.commit() 421 422flask.session["username"] = username 423 424flask.flash("You have been registered and logged in.") 425 426return flask.redirect("/") 427 428 429@app.route("/profile", defaults={"username": None}) 430@app.route("/profile/<username>") 431def profile(username): 432if username is None: 433if "username" in flask.session: 434return flask.redirect("/profile/" + flask.session["username"]) 435else: 436flask.flash("Please log in to perform this action.") 437return flask.redirect("/accounts") 438 439user = db.session.get(User, username) 440if user is None: 441flask.abort(404) 442 443return flask.render_template("profile.html", user=user) 444 445 446@app.route("/object/<id>") 447def has_object(id): 448object_ = db.session.get(PictureObject, id) 449if object_ is None: 450flask.abort(404) 451 452descendants_cte = ( 453db.select(PictureObject.id) 454.where(PictureObject.id == id) 455.cte(name="descendants_cte", recursive=True) 456) 457 458descendants_cte = descendants_cte.union_all( 459db.select(PictureObjectInheritance.child_id) 460.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 461) 462 463query = db.session.query(PictureResource).filter( 464PictureResource.regions.any( 465PictureRegion.object_id.in_( 466db.select(descendants_cte.c.id) 467) 468) 469) 470 471page = int(flask.request.args.get("page", 1)) 472per_page = int(flask.request.args.get("per_page", 16)) 473 474resources = query.paginate(page=page, per_page=per_page) 475 476return flask.render_template("object.html", object=object_, resources=resources, 477page_number=page, 478page_length=per_page, num_pages=resources.pages, 479prev_page=resources.prev_num, 480next_page=resources.next_num, PictureRegion=PictureRegion) 481 482 483@app.route("/upload") 484def upload(): 485if "username" not in flask.session: 486flask.flash("Log in to upload pictures.") 487return flask.redirect("/accounts") 488 489licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 490Licence.title).all() 491 492types = PictureNature.query.all() 493 494return flask.render_template("upload.html", licences=licences, types=types) 495 496 497@app.route("/upload", methods=["POST"]) 498def upload_post(): 499title = flask.request.form["title"] 500description = flask.request.form["description"] 501origin_url = flask.request.form["origin_url"] 502author = db.session.get(User, flask.session.get("username")) 503licence_ids = flask.request.form.getlist("licence") 504nature_id = flask.request.form["nature"] 505 506if author is None: 507flask.abort(401) 508 509file = flask.request.files["file"] 510 511if not file or not file.filename: 512flask.flash("Select a file") 513return flask.redirect(flask.request.url) 514 515if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 516flask.flash("Only images are supported") 517return flask.redirect(flask.request.url) 518 519if not title: 520flask.flash("Enter a title") 521return flask.redirect(flask.request.url) 522 523if not description: 524description = "" 525 526if not nature_id: 527flask.flash("Select a picture type") 528return flask.redirect(flask.request.url) 529 530if not licence_ids: 531flask.flash("Select licences") 532return flask.redirect(flask.request.url) 533 534licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 535if not any(licence.free for licence in licences): 536flask.flash("Select at least one free licence") 537return flask.redirect(flask.request.url) 538 539resource = PictureResource(title, author, description, origin_url, licence_ids, 540file.mimetype, 541db.session.get(PictureNature, nature_id)) 542db.session.add(resource) 543db.session.commit() 544file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 545pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 546resource.width, resource.height = pil_image.size 547pil_image.thumbnail(config.THUMBNAIL_SIZE) 548pil_image = pil_image.convert("RGB") 549pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 550db.session.commit() 551 552if flask.request.form.get("annotations"): 553try: 554resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 555db.session.commit() 556except json.JSONDecodeError: 557flask.flash("Invalid annotations") 558 559flask.flash("Picture uploaded successfully") 560 561return flask.redirect("/picture/" + str(resource.id)) 562 563 564@app.route("/picture/<int:id>/") 565def picture(id): 566resource = db.session.get(PictureResource, id) 567if resource is None: 568flask.abort(404) 569 570image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 571 572current_user = db.session.get(User, flask.session.get("username")) 573have_permission = current_user and (current_user == resource.author or current_user.admin) 574 575own_rating = None 576if current_user: 577own_rating = PictureRating.query.filter_by(resource=resource, user=current_user).first() 578 579return flask.render_template("picture.html", resource=resource, 580file_extension=mimetypes.guess_extension(resource.file_format), 581size=image.size, copies=resource.copies, 582have_permission=have_permission, own_rating=own_rating) 583 584 585@app.route("/picture/<int:id>/annotate") 586def annotate_picture(id): 587resource = db.session.get(PictureResource, id) 588if resource is None: 589flask.abort(404) 590 591current_user = db.session.get(User, flask.session.get("username")) 592if current_user is None: 593flask.abort(401) 594 595if resource.author != current_user and not current_user.admin: 596flask.abort(403) 597 598return flask.render_template("picture-annotation.html", resource=resource, 599file_extension=mimetypes.guess_extension(resource.file_format)) 600 601 602@app.route("/picture/<int:id>/put-annotations-form") 603def put_annotations_form(id): 604resource = db.session.get(PictureResource, id) 605if resource is None: 606flask.abort(404) 607 608current_user = db.session.get(User, flask.session.get("username")) 609if current_user is None: 610flask.abort(401) 611 612if resource.author != current_user and not current_user.admin: 613flask.abort(403) 614 615return flask.render_template("put-annotations-form.html", resource=resource) 616 617 618@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 619def put_annotations_form_post(id): 620resource = db.session.get(PictureResource, id) 621if resource is None: 622flask.abort(404) 623 624current_user = db.session.get(User, flask.session.get("username")) 625if current_user is None: 626flask.abort(401) 627 628if resource.author != current_user and not current_user.admin: 629flask.abort(403) 630 631resource.put_annotations(json.loads(flask.request.form["annotations"])) 632 633db.session.commit() 634 635return flask.redirect("/picture/" + str(resource.id)) 636 637 638@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 639@app.route("/api/picture/<int:id>/put-annotations", methods=["POST"]) 640def save_annotations(id): 641resource = db.session.get(PictureResource, id) 642if resource is None: 643flask.abort(404) 644 645current_user = db.session.get(User, flask.session.get("username")) 646if resource.author != current_user and not current_user.admin: 647flask.abort(403) 648 649resource.put_annotations(flask.request.json) 650 651db.session.commit() 652 653response = flask.make_response() 654response.status_code = 204 655return response 656 657 658@app.route("/picture/<int:id>/get-annotations") 659@app.route("/api/picture/<int:id>/api/get-annotations") 660def get_annotations(id): 661resource = db.session.get(PictureResource, id) 662if resource is None: 663flask.abort(404) 664 665regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 666 667regions_json = [] 668 669for region in regions: 670regions_json.append({ 671"object": region.object_id, 672"type": region.json["type"], 673"shape": region.json["shape"], 674}) 675 676return flask.jsonify(regions_json) 677 678 679@app.route("/picture/<int:id>/delete") 680def delete_picture(id): 681resource = db.session.get(PictureResource, id) 682if resource is None: 683flask.abort(404) 684 685current_user = db.session.get(User, flask.session.get("username")) 686if current_user is None: 687flask.abort(401) 688 689if resource.author != current_user and not current_user.admin: 690flask.abort(403) 691 692PictureLicence.query.filter_by(resource=resource).delete() 693PictureRegion.query.filter_by(resource=resource).delete() 694PictureInGallery.query.filter_by(resource=resource).delete() 695PictureRating.query.filter_by(resource=resource).delete() 696if resource.replaces: 697resource.replaces.replaced_by = None 698if resource.replaced_by: 699resource.replaced_by.replaces = None 700resource.copied_from = None 701for copy in resource.copies: 702copy.copied_from = None 703db.session.delete(resource) 704db.session.commit() 705 706return flask.redirect("/") 707 708 709@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 710def mark_picture_replacement(id): 711resource = db.session.get(PictureResource, id) 712if resource is None: 713flask.abort(404) 714 715current_user = db.session.get(User, flask.session.get("username")) 716if current_user is None: 717flask.abort(401) 718 719if resource.copied_from.author != current_user and not current_user.admin: 720flask.abort(403) 721 722resource.copied_from.replaced_by = resource 723resource.replaces = resource.copied_from 724 725db.session.commit() 726 727return flask.redirect("/picture/" + str(resource.copied_from.id)) 728 729 730@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 731def remove_picture_replacement(id): 732resource = db.session.get(PictureResource, id) 733if resource is None: 734flask.abort(404) 735 736current_user = db.session.get(User, flask.session.get("username")) 737if current_user is None: 738flask.abort(401) 739 740if resource.author != current_user and not current_user.admin: 741flask.abort(403) 742 743resource.replaced_by.replaces = None 744resource.replaced_by = None 745 746db.session.commit() 747 748return flask.redirect("/picture/" + str(resource.id)) 749 750 751@app.route("/picture/<int:id>/edit-metadata") 752def edit_picture(id): 753resource = db.session.get(PictureResource, id) 754if resource is None: 755flask.abort(404) 756 757current_user = db.session.get(User, flask.session.get("username")) 758if current_user is None: 759flask.abort(401) 760 761if resource.author != current_user and not current_user.admin: 762flask.abort(403) 763 764licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 765Licence.title).all() 766 767types = PictureNature.query.all() 768 769return flask.render_template("edit-picture.html", resource=resource, licences=licences, 770types=types, 771PictureLicence=PictureLicence) 772 773 774@app.route("/picture/<int:id>/rate", methods=["POST"]) 775def rate_picture(id): 776resource = db.session.get(PictureResource, id) 777if resource is None: 778flask.abort(404) 779 780current_user = db.session.get(User, flask.session.get("username")) 781if current_user is None: 782flask.abort(401) 783 784rating = int(flask.request.form.get("rating")) 785 786if not rating: 787# Delete the existing rating 788if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 789db.session.delete(PictureRating.query.filter_by(resource=resource, 790user=current_user).first()) 791db.session.commit() 792 793return flask.redirect("/picture/" + str(resource.id)) 794 795if not 1 <= rating <= 5: 796flask.flash("Invalid rating") 797return flask.redirect("/picture/" + str(resource.id)) 798 799if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 800PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 801else: 802# Create a new rating 803db.session.add(PictureRating(resource, current_user, rating)) 804 805db.session.commit() 806 807return flask.redirect("/picture/" + str(resource.id)) 808 809 810@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 811def edit_picture_post(id): 812resource = db.session.get(PictureResource, id) 813if resource is None: 814flask.abort(404) 815 816current_user = db.session.get(User, flask.session.get("username")) 817if current_user is None: 818flask.abort(401) 819 820if resource.author != current_user and not current_user.admin: 821flask.abort(403) 822 823title = flask.request.form["title"] 824description = flask.request.form["description"] 825origin_url = flask.request.form["origin_url"] 826licence_ids = flask.request.form.getlist("licence") 827nature_id = flask.request.form["nature"] 828 829if not title: 830flask.flash("Enter a title") 831return flask.redirect(flask.request.url) 832 833if not description: 834description = "" 835 836if not nature_id: 837flask.flash("Select a picture type") 838return flask.redirect(flask.request.url) 839 840if not licence_ids: 841flask.flash("Select licences") 842return flask.redirect(flask.request.url) 843 844licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 845if not any(licence.free for licence in licences): 846flask.flash("Select at least one free licence") 847return flask.redirect(flask.request.url) 848 849resource.title = title 850resource.description = description 851resource.origin_url = origin_url 852for licence_id in licence_ids: 853joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 854db.session.add(joiner) 855resource.nature = db.session.get(PictureNature, nature_id) 856 857db.session.commit() 858 859return flask.redirect("/picture/" + str(resource.id)) 860 861 862@app.route("/picture/<int:id>/copy") 863def copy_picture(id): 864resource = db.session.get(PictureResource, id) 865if resource is None: 866flask.abort(404) 867 868current_user = db.session.get(User, flask.session.get("username")) 869if current_user is None: 870flask.abort(401) 871 872new_resource = PictureResource(resource.title, current_user, resource.description, 873resource.origin_url, 874[licence.licence_id for licence in resource.licences], 875resource.file_format, 876resource.nature) 877 878for region in resource.regions: 879db.session.add(PictureRegion(region.json, new_resource, region.object)) 880 881db.session.commit() 882 883# Create a hard link for the new picture 884old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 885new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 886os.link(old_path, new_path) 887 888new_resource.width = resource.width 889new_resource.height = resource.height 890new_resource.copied_from = resource 891 892db.session.commit() 893 894return flask.redirect("/picture/" + str(new_resource.id)) 895 896 897@app.route("/gallery/<int:id>/") 898def gallery(id): 899gallery = db.session.get(Gallery, id) 900if gallery is None: 901flask.abort(404) 902 903current_user = db.session.get(User, flask.session.get("username")) 904 905have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 906have_extended_permission = current_user and (current_user == gallery.owner or current_user.admin) 907 908return flask.render_template("gallery.html", gallery=gallery, 909have_permission=have_permission, 910have_extended_permission=have_extended_permission) 911 912 913@app.route("/create-gallery") 914def create_gallery(): 915if "username" not in flask.session: 916flask.flash("Log in to create galleries.") 917return flask.redirect("/accounts") 918 919return flask.render_template("create-gallery.html") 920 921 922@app.route("/create-gallery", methods=["POST"]) 923def create_gallery_post(): 924if not flask.session.get("username"): 925flask.abort(401) 926 927if not flask.request.form.get("title"): 928flask.flash("Enter a title") 929return flask.redirect(flask.request.url) 930 931description = flask.request.form.get("description", "") 932 933gallery = Gallery(flask.request.form["title"], description, 934db.session.get(User, flask.session["username"])) 935db.session.add(gallery) 936db.session.commit() 937 938return flask.redirect("/gallery/" + str(gallery.id)) 939 940 941@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 942def gallery_add_picture(id): 943gallery = db.session.get(Gallery, id) 944if gallery is None: 945flask.abort(404) 946 947if "username" not in flask.session: 948flask.abort(401) 949 950if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 951flask.abort(403) 952 953picture_id = flask.request.form.get("picture_id") 954if "/" in picture_id: # also allow full URLs 955picture_id = picture_id.rstrip("/").rpartition("/")[1] 956if not picture_id: 957flask.flash("Select a picture") 958return flask.redirect("/gallery/" + str(gallery.id)) 959picture_id = int(picture_id) 960 961picture = db.session.get(PictureResource, picture_id) 962if picture is None: 963flask.flash("Invalid picture") 964return flask.redirect("/gallery/" + str(gallery.id)) 965 966if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 967flask.flash("This picture is already in the gallery") 968return flask.redirect("/gallery/" + str(gallery.id)) 969 970db.session.add(PictureInGallery(picture, gallery)) 971 972db.session.commit() 973 974return flask.redirect("/gallery/" + str(gallery.id)) 975 976 977@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 978def gallery_remove_picture(id): 979gallery = db.session.get(Gallery, id) 980if gallery is None: 981flask.abort(404) 982 983if "username" not in flask.session: 984flask.abort(401) 985 986current_user = db.session.get(User, flask.session.get("username")) 987 988if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 989flask.abort(403) 990 991picture_id = int(flask.request.form.get("picture_id")) 992 993picture = db.session.get(PictureResource, picture_id) 994if picture is None: 995flask.flash("Invalid picture") 996return flask.redirect("/gallery/" + str(gallery.id)) 997 998picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 999gallery=gallery).first() 1000if picture_in_gallery is None: 1001flask.flash("This picture isn't in the gallery") 1002return flask.redirect("/gallery/" + str(gallery.id)) 1003 1004db.session.delete(picture_in_gallery) 1005 1006db.session.commit() 1007 1008return flask.redirect("/gallery/" + str(gallery.id)) 1009 1010 1011@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 1012def gallery_add_from_query(id): 1013gallery = db.session.get(Gallery, id) 1014if gallery is None: 1015flask.abort(404) 1016 1017if "username" not in flask.session: 1018flask.abort(401) 1019 1020if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1021flask.abort(403) 1022 1023query_yaml = flask.request.form.get("query", "") 1024 1025yaml_parser = yaml.YAML() 1026query_data = yaml_parser.load(query_yaml) or {} 1027query = get_picture_query(query_data) 1028 1029pictures = query.all() 1030 1031count = 0 1032 1033for picture in pictures: 1034if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1035db.session.add(PictureInGallery(picture, gallery)) 1036count += 1 1037 1038db.session.commit() 1039 1040flask.flash(f"Added {count} pictures to the gallery") 1041 1042return flask.redirect("/gallery/" + str(gallery.id)) 1043 1044 1045@app.route("/gallery/<int:id>/users") 1046def gallery_users(id): 1047gallery = db.session.get(Gallery, id) 1048if gallery is None: 1049flask.abort(404) 1050 1051current_user = db.session.get(User, flask.session.get("username")) 1052have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1053 1054return flask.render_template("gallery-users.html", gallery=gallery, 1055have_permission=have_permission) 1056 1057 1058@app.route("/gallery/<int:id>/edit") 1059def edit_gallery(id): 1060gallery = db.session.get(Gallery, id) 1061if gallery is None: 1062flask.abort(404) 1063 1064current_user = db.session.get(User, flask.session.get("username")) 1065if current_user is None: 1066flask.abort(401) 1067 1068if current_user != gallery.owner and not current_user.admin: 1069flask.abort(403) 1070 1071return flask.render_template("edit-gallery.html", gallery=gallery) 1072 1073 1074@app.route("/gallery/<int:id>/edit", methods=["POST"]) 1075def edit_gallery_post(id): 1076gallery = db.session.get(Gallery, id) 1077if gallery is None: 1078flask.abort(404) 1079 1080current_user = db.session.get(User, flask.session.get("username")) 1081if current_user is None: 1082flask.abort(401) 1083 1084if current_user != gallery.owner and not current_user.admin: 1085flask.abort(403) 1086 1087title = flask.request.form["title"] 1088description = flask.request.form.get("description") 1089 1090if not title: 1091flask.flash("Enter a title") 1092return flask.redirect(flask.request.url) 1093 1094if not description: 1095description = "" 1096 1097gallery.title = title 1098gallery.description = description 1099 1100db.session.commit() 1101 1102return flask.redirect("/gallery/" + str(gallery.id)) 1103 1104 1105@app.route("/gallery/<int:id>/delete") 1106def delete_gallery(id): 1107gallery = db.session.get(Gallery, id) 1108if gallery is None: 1109flask.abort(404) 1110 1111current_user = db.session.get(User, flask.session.get("username")) 1112if current_user is None: 1113flask.abort(401) 1114 1115if current_user != gallery.owner and not current_user.admin: 1116flask.abort(403) 1117 1118PictureInGallery.query.filter_by(gallery=gallery).delete() 1119UserInGallery.query.filter_by(gallery=gallery).delete() 1120db.session.delete(gallery) 1121db.session.commit() 1122 1123return flask.redirect("/") 1124 1125 1126@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1127def gallery_add_user(id): 1128gallery = db.session.get(Gallery, id) 1129if gallery is None: 1130flask.abort(404) 1131 1132current_user = db.session.get(User, flask.session.get("username")) 1133if current_user is None: 1134flask.abort(401) 1135 1136if current_user != gallery.owner and not current_user.admin: 1137flask.abort(403) 1138 1139username = flask.request.form.get("username") 1140if username == gallery.owner_name: 1141flask.flash("The owner is already in the gallery") 1142return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1143 1144user = db.session.get(User, username) 1145if user is None: 1146flask.flash("User not found") 1147return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1148 1149if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1150flask.flash("User is already in the gallery") 1151return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1152 1153db.session.add(UserInGallery(user, gallery)) 1154 1155db.session.commit() 1156 1157return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1158 1159 1160@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1161def gallery_remove_user(id): 1162gallery = db.session.get(Gallery, id) 1163if gallery is None: 1164flask.abort(404) 1165 1166current_user = db.session.get(User, flask.session.get("username")) 1167if current_user is None: 1168flask.abort(401) 1169 1170if current_user != gallery.owner and not current_user.admin: 1171flask.abort(403) 1172 1173username = flask.request.form.get("username") 1174user = db.session.get(User, username) 1175if user is None: 1176flask.flash("User not found") 1177return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1178 1179user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1180if user_in_gallery is None: 1181flask.flash("User is not in the gallery") 1182return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1183 1184db.session.delete(user_in_gallery) 1185 1186db.session.commit() 1187 1188return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1189 1190 1191class APIError(Exception): 1192def __init__(self, status_code, message): 1193self.status_code = status_code 1194self.message = message 1195 1196 1197def get_picture_query(query_data): 1198query = db.session.query(PictureResource) 1199 1200def has_condition(id): 1201descendants_cte = ( 1202db.select(PictureObject.id) 1203.where(PictureObject.id == id) 1204.cte(name=f"descendants_cte_{id}", recursive=True) 1205) 1206 1207descendants_cte = descendants_cte.union_all( 1208db.select(PictureObjectInheritance.child_id) 1209.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 1210) 1211 1212return PictureResource.regions.any( 1213PictureRegion.object_id.in_( 1214db.select(descendants_cte.c.id) 1215) 1216) 1217 1218requirement_conditions = { 1219# Has an object with the ID in the given list 1220"has_object": lambda value: PictureResource.regions.any( 1221PictureRegion.object_id.in_(value)), 1222# Has an object with the ID in the given list, or a subtype of it 1223"has": lambda value: db.or_(*[has_condition(id) for id in value]), 1224"nature": lambda value: PictureResource.nature_id.in_(value), 1225"licence": lambda value: PictureResource.licences.any( 1226PictureLicence.licence_id.in_(value)), 1227"author": lambda value: PictureResource.author_name.in_(value), 1228"title": lambda value: PictureResource.title.ilike(value), 1229"description": lambda value: PictureResource.description.ilike(value), 1230"origin_url": lambda value: db.func.lower(db.func.substr( 1231PictureResource.origin_url, 1232db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1233)).in_(value), 1234"above_width": lambda value: PictureResource.width >= value, 1235"below_width": lambda value: PictureResource.width <= value, 1236"above_height": lambda value: PictureResource.height >= value, 1237"below_height": lambda value: PictureResource.height <= value, 1238"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1239value), 1240"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1241value), 1242"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1243"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value, 1244"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value, 1245"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1246"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1247"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1248"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1249"copied_from": lambda value: PictureResource.copied_from_id.in_(value), 1250} 1251 1252if "want" in query_data: 1253for i in query_data["want"]: 1254if len(i) != 1: 1255raise APIError(400, "Each requirement must have exactly one key") 1256requirement, value = list(i.items())[0] 1257if requirement not in requirement_conditions: 1258raise APIError(400, f"Unknown requirement type: {requirement}") 1259 1260condition = requirement_conditions[requirement] 1261query = query.filter(condition(value)) 1262if "exclude" in query_data: 1263for i in query_data["exclude"]: 1264if len(i) != 1: 1265raise APIError(400, "Each exclusion must have exactly one key") 1266requirement, value = list(i.items())[0] 1267if requirement not in requirement_conditions: 1268raise APIError(400, f"Unknown requirement type: {requirement}") 1269 1270condition = requirement_conditions[requirement] 1271query = query.filter(~condition(value)) 1272if not query_data.get("include_obsolete", False): 1273query = query.filter(PictureResource.replaced_by_id.is_(None)) 1274 1275return query 1276 1277 1278@app.route("/query-pictures") 1279def graphical_query_pictures(): 1280return flask.render_template("graphical-query-pictures.html") 1281 1282 1283@app.route("/query-pictures-results") 1284def graphical_query_pictures_results(): 1285query_yaml = flask.request.args.get("query", "") 1286yaml_parser = yaml.YAML() 1287query_data = yaml_parser.load(query_yaml) or {} 1288try: 1289query = get_picture_query(query_data) 1290except APIError as e: 1291flask.abort(e.status_code) 1292 1293page = int(flask.request.args.get("page", 1)) 1294per_page = int(flask.request.args.get("per_page", 16)) 1295 1296resources = query.paginate(page=page, per_page=per_page) 1297 1298return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1299query=query_yaml, 1300page_number=page, page_length=per_page, 1301num_pages=resources.pages, 1302prev_page=resources.prev_num, next_page=resources.next_num) 1303 1304 1305@app.route("/raw/picture/<int:id>") 1306def raw_picture(id): 1307resource = db.session.get(PictureResource, id) 1308if resource is None: 1309flask.abort(404) 1310 1311response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1312str(resource.id)) 1313response.mimetype = resource.file_format 1314 1315return response 1316 1317 1318@app.route("/raw/picture-thumbnail/<int:id>") 1319def raw_picture_thumbnail(id): 1320resource = db.session.get(PictureResource, id) 1321if resource is None: 1322flask.abort(404) 1323 1324if not path.exists(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id))): 1325pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1326pil_image.thumbnail(config.THUMBNAIL_SIZE) 1327pil_image = pil_image.convert("RGB") 1328pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1329 1330response = flask.send_from_directory(path.join(config.DATA_PATH, "picture-thumbnails"), 1331str(resource.id)) 1332response.mimetype = resource.file_format 1333 1334return response 1335 1336 1337@app.route("/object/") 1338def graphical_object_types(): 1339return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1340 1341 1342@app.route("/api/object-types") 1343def object_types(): 1344objects = db.session.query(PictureObject).all() 1345return flask.jsonify({object.id: object.description for object in objects}) 1346 1347 1348@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1349def query_pictures(): 1350offset = int(flask.request.args.get("offset", 0)) 1351limit = int(flask.request.args.get("limit", 16)) 1352ordering = flask.request.args.get("ordering", "date-desc") 1353 1354yaml_parser = yaml.YAML() 1355query_data = yaml_parser.load(flask.request.data) or {} 1356try: 1357query = get_picture_query(query_data) 1358except APIError as e: 1359return flask.jsonify({"error": e.message}), e.status_code 1360 1361rating_count_subquery = db.select(db.func.count(PictureRating.id)).where( 1362PictureRating.resource_id == PictureResource.id).scalar_subquery() 1363region_count_subquery = db.select(db.func.count(PictureRegion.id)).where( 1364PictureRegion.resource_id == PictureResource.id).scalar_subquery() 1365rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where( 1366PictureRating.resource_id == PictureResource.id).scalar_subquery() 1367 1368match ordering: 1369case "date-desc": 1370query = query.order_by(PictureResource.timestamp.desc()) 1371case "date-asc": 1372query = query.order_by(PictureResource.timestamp.asc()) 1373case "title-asc": 1374query = query.order_by(PictureResource.title.asc()) 1375case "title-desc": 1376query = query.order_by(PictureResource.title.desc()) 1377case "random": 1378query = query.order_by(db.func.random()) 1379case "number-regions-desc": 1380query = query.order_by(region_count_subquery.desc()) 1381case "number-regions-asc": 1382query = query.order_by(region_count_subquery.asc()) 1383case "rating-desc": 1384query = query.order_by(rating_subquery.desc()) 1385case "rating-asc": 1386query = query.order_by(rating_subquery.asc()) 1387case "number-ratings-desc": 1388query = query.order_by(rating_count_subquery.desc()) 1389case "number-ratings-asc": 1390query = query.order_by(rating_count_subquery.asc()) 1391 1392query = query.offset(offset).limit(limit) 1393resources = query.all() 1394 1395json_response = { 1396"date_generated": datetime.utcnow().timestamp(), 1397"resources": [], 1398"offset": offset, 1399"limit": limit, 1400} 1401 1402json_resources = json_response["resources"] 1403 1404for resource in resources: 1405json_resource = { 1406"id": resource.id, 1407"title": resource.title, 1408"description": resource.description, 1409"timestamp": resource.timestamp.timestamp(), 1410"origin_url": resource.origin_url, 1411"author": resource.author_name, 1412"file_format": resource.file_format, 1413"width": resource.width, 1414"height": resource.height, 1415"nature": resource.nature_id, 1416"licences": [licence.licence_id for licence in resource.licences], 1417"replaces": resource.replaces_id, 1418"replaced_by": resource.replaced_by_id, 1419"regions": [], 1420"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1421} 1422for region in resource.regions: 1423json_resource["regions"].append({ 1424"object": region.object_id, 1425"type": region.json["type"], 1426"shape": region.json["shape"], 1427}) 1428 1429json_resources.append(json_resource) 1430 1431return flask.jsonify(json_response) 1432 1433 1434@app.route("/api/picture/<int:id>/") 1435def api_picture(id): 1436resource = db.session.get(PictureResource, id) 1437if resource is None: 1438flask.abort(404) 1439 1440json_resource = { 1441"id": resource.id, 1442"title": resource.title, 1443"description": resource.description, 1444"timestamp": resource.timestamp.timestamp(), 1445"origin_url": resource.origin_url, 1446"author": resource.author_name, 1447"file_format": resource.file_format, 1448"width": resource.width, 1449"height": resource.height, 1450"nature": resource.nature_id, 1451"licences": [licence.licence_id for licence in resource.licences], 1452"replaces": resource.replaces_id, 1453"replaced_by": resource.replaced_by_id, 1454"regions": [], 1455"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1456"rating_average": resource.average_rating, 1457"rating_count": resource.rating_totals, 1458} 1459for region in resource.regions: 1460json_resource["regions"].append({ 1461"object": region.object_id, 1462"type": region.json["type"], 1463"shape": region.json["shape"], 1464}) 1465 1466return flask.jsonify(json_resource) 1467 1468 1469@app.route("/api/licence/") 1470def api_licences(): 1471licences = db.session.query(Licence).all() 1472json_licences = { 1473licence.id: { 1474"title": licence.title, 1475"free": licence.free, 1476"pinned": licence.pinned, 1477} for licence in licences 1478} 1479 1480return flask.jsonify(json_licences) 1481 1482 1483@app.route("/api/licence/<id>/") 1484def api_licence(id): 1485licence = db.session.get(Licence, id) 1486if licence is None: 1487flask.abort(404) 1488 1489json_licence = { 1490"id": licence.id, 1491"title": licence.title, 1492"description": licence.description, 1493"info_url": licence.info_url, 1494"legalese_url": licence.url, 1495"free": licence.free, 1496"logo_url": licence.logo_url, 1497"pinned": licence.pinned, 1498} 1499 1500return flask.jsonify(json_licence) 1501 1502 1503@app.route("/api/nature/") 1504def api_natures(): 1505natures = db.session.query(PictureNature).all() 1506json_natures = { 1507nature.id: nature.description for nature in natures 1508} 1509 1510return flask.jsonify(json_natures) 1511 1512 1513@app.route("/api/user/") 1514def api_users(): 1515offset = int(flask.request.args.get("offset", 0)) 1516limit = int(flask.request.args.get("limit", 16)) 1517 1518users = db.session.query(User).offset(offset).limit(limit).all() 1519 1520json_users = { 1521user.username: { 1522"admin": user.admin, 1523} for user in users 1524} 1525 1526return flask.jsonify(json_users) 1527 1528 1529@app.route("/api/user/<username>/") 1530def api_user(username): 1531user = db.session.get(User, username) 1532if user is None: 1533flask.abort(404) 1534 1535json_user = { 1536"username": user.username, 1537"admin": user.admin, 1538"joined": user.joined_timestamp.timestamp(), 1539} 1540 1541return flask.jsonify(json_user) 1542 1543 1544@app.route("/api/login", methods=["POST"]) 1545def api_login(): 1546username = flask.request.json["username"] 1547password = flask.request.json["password"] 1548 1549user = db.session.get(User, username) 1550 1551if user is None: 1552return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1553 1554if not bcrypt.check_password_hash(user.password_hashed, password): 1555return flask.jsonify({"error": "Incorrect password"}), 401 1556 1557flask.session["username"] = username 1558 1559return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1560 1561 1562@app.route("/api/logout", methods=["POST"]) 1563def api_logout(): 1564flask.session.pop("username", None) 1565return flask.jsonify({"message": "You have been logged out."}) 1566 1567 1568@app.route("/api/upload", methods=["POST"]) 1569def api_upload(): 1570if "username" not in flask.session: 1571return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1572 1573json_ = json.loads(flask.request.form["json"]) 1574title = json_["title"] 1575description = json_.get("description", "") 1576origin_url = json_.get("origin_url", "") 1577author = db.session.get(User, flask.session["username"]) 1578licence_ids = json_["licence"] 1579nature_id = json_["nature"] 1580file = flask.request.files["file"] 1581 1582if not file or not file.filename: 1583return flask.jsonify({"error": "An image file must be uploaded"}), 400 1584 1585if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1586return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1587 1588if not title: 1589return flask.jsonify({"error": "Give a title"}), 400 1590 1591if not description: 1592description = "" 1593 1594if not nature_id: 1595return flask.jsonify({"error": "Give a picture type"}), 400 1596 1597if not licence_ids: 1598return flask.jsonify({"error": "Give licences"}), 400 1599 1600licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1601if not any(licence.free for licence in licences): 1602return flask.jsonify({"error": "Use at least one free licence"}), 400 1603 1604resource = PictureResource(title, author, description, origin_url, licence_ids, 1605file.mimetype, 1606db.session.get(PictureNature, nature_id)) 1607db.session.add(resource) 1608db.session.commit() 1609file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1610pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1611resource.width, resource.height = pil_image.size 1612pil_image.thumbnail(config.THUMBNAIL_SIZE) 1613pil_image = pil_image.convert("RGB") 1614pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1615db.session.commit() 1616 1617if json_.get("annotations"): 1618try: 1619resource.put_annotations(json_["annotations"]) 1620db.session.commit() 1621except json.JSONDecodeError: 1622return flask.jsonify({"error": "Invalid annotations"}), 400 1623 1624return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id}) 1625 1626 1627@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1628def api_update_picture(id): 1629resource = db.session.get(PictureResource, id) 1630if resource is None: 1631return flask.jsonify({"error": "Picture not found"}), 404 1632current_user = db.session.get(User, flask.session.get("username")) 1633if current_user is None: 1634return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1635if resource.author != current_user and not current_user.admin: 1636return flask.jsonify({"error": "You are not the author of this picture"}), 403 1637 1638title = flask.request.json.get("title", resource.title) 1639description = flask.request.json.get("description", resource.description) 1640origin_url = flask.request.json.get("origin_url", resource.origin_url) 1641licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1642nature_id = flask.request.json.get("nature", resource.nature_id) 1643 1644if not title: 1645return flask.jsonify({"error": "Give a title"}), 400 1646 1647if not description: 1648description = "" 1649 1650if not nature_id: 1651return flask.jsonify({"error": "Give a picture type"}), 400 1652 1653if not licence_ids: 1654return flask.jsonify({"error": "Give licences"}), 400 1655 1656licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1657 1658if not any(licence.free for licence in licences): 1659return flask.jsonify({"error": "Use at least one free licence"}), 400 1660 1661resource.title = title 1662resource.description = description 1663resource.origin_url = origin_url 1664resource.licences = licences 1665resource.nature = db.session.get(PictureNature, nature_id) 1666 1667db.session.commit() 1668 1669return flask.jsonify({"message": "Picture updated successfully"}) 1670 1671 1672@app.route("/api/picture/<int:id>/rate", methods=["POST"]) 1673def api_rate_picture(id): 1674resource = db.session.get(PictureResource, id) 1675if resource is None: 1676flask.abort(404) 1677 1678current_user = db.session.get(User, flask.session.get("username")) 1679if current_user is None: 1680flask.abort(401) 1681 1682rating = int(flask.request.json.get("rating", 0)) 1683 1684if not rating: 1685# Delete the existing rating 1686if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1687db.session.delete(PictureRating.query.filter_by(resource=resource, 1688user=current_user).first()) 1689db.session.commit() 1690 1691return flask.jsonify({"message": "Existing rating removed"}) 1692 1693if not 1 <= rating <= 5: 1694flask.flash("Invalid rating") 1695return flask.jsonify({"error": "Invalid rating"}), 400 1696 1697if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1698PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 1699else: 1700# Create a new rating 1701db.session.add(PictureRating(resource, current_user, rating)) 1702 1703db.session.commit() 1704 1705return flask.jsonify({"message": "Rating saved"}) 1706 1707 1708@app.route("/api/gallery/<int:id>/") 1709def api_gallery(id): 1710gallery = db.session.get(Gallery, id) 1711if gallery is None: 1712flask.abort(404) 1713 1714json_gallery = { 1715"id": gallery.id, 1716"title": gallery.title, 1717"description": gallery.description, 1718"owner": gallery.owner_name, 1719"users": [user.username for user in gallery.users], 1720} 1721 1722return flask.jsonify(json_gallery) 1723 1724 1725@app.route("/api/gallery/<int:id>/edit", methods=["POST"]) 1726def api_edit_gallery(id): 1727gallery = db.session.get(Gallery, id) 1728if gallery is None: 1729flask.abort(404) 1730 1731current_user = db.session.get(User, flask.session.get("username")) 1732if current_user is None: 1733flask.abort(401) 1734 1735if current_user != gallery.owner and not current_user.admin: 1736flask.abort(403) 1737 1738title = flask.request.json.get("title", gallery.title) 1739description = flask.request.json.get("description", gallery.description) 1740 1741if not title: 1742return flask.jsonify({"error": "Give a title"}), 400 1743 1744if not description: 1745description = "" 1746 1747gallery.title = title 1748gallery.description = description 1749 1750db.session.commit() 1751 1752return flask.jsonify({"message": "Gallery updated successfully"}) 1753 1754 1755@app.route("/api/new-gallery", methods=["POST"]) 1756def api_new_gallery(): 1757if "username" not in flask.session: 1758return flask.jsonify({"error": "You must be logged in to create galleries"}), 401 1759 1760title = flask.request.json.get("title") 1761description = flask.request.json.get("description", "") 1762 1763if not title: 1764return flask.jsonify({"error": "Give a title"}), 400 1765 1766gallery = Gallery(title, description, db.session.get(User, flask.session["username"])) 1767db.session.add(gallery) 1768db.session.commit() 1769 1770return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id}) 1771 1772 1773@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"]) 1774def api_gallery_add_picture(id): 1775gallery = db.session.get(Gallery, id) 1776if gallery is None: 1777flask.abort(404) 1778 1779if "username" not in flask.session: 1780return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401 1781 1782current_user = db.session.get(User, flask.session.get("username")) 1783 1784if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1785return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403 1786 1787picture_id = flask.request.json.get("picture_id") 1788 1789try: 1790picture_id = int(picture_id) 1791except ValueError: 1792return flask.jsonify({"error": "Invalid picture ID"}), 400 1793 1794picture = db.session.get(PictureResource, picture_id) 1795if picture is None: 1796return flask.jsonify({"error": "The picture doesn't exist"}), 404 1797 1798if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1799return flask.jsonify({"error": "This picture is already in the gallery"}), 400 1800 1801db.session.add(PictureInGallery(picture, gallery)) 1802 1803db.session.commit() 1804 1805return flask.jsonify({"message": "Picture added to gallery"}) 1806 1807 1808@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"]) 1809def api_gallery_remove_picture(id): 1810gallery = db.session.get(Gallery, id) 1811if gallery is None: 1812flask.abort(404) 1813 1814if "username" not in flask.session: 1815return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401 1816 1817current_user = db.session.get(User, flask.session.get("username")) 1818 1819if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1820return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403 1821 1822picture_id = flask.request.json.get("picture_id") 1823 1824try: 1825picture_id = int(picture_id) 1826except ValueError: 1827return flask.jsonify({"error": "Invalid picture ID"}), 400 1828 1829picture = db.session.get(PictureResource, picture_id) 1830if picture is None: 1831return flask.jsonify({"error": "The picture doesn't exist"}), 404 1832 1833picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 1834if picture_in_gallery is None: 1835return flask.jsonify({"error": "This picture isn't in the gallery"}), 400 1836 1837db.session.delete(picture_in_gallery) 1838 1839db.session.commit() 1840 1841return flask.jsonify({"message": "Picture removed from gallery"}) 1842 1843 1844@app.route("/api/gallery/<int:id>/users/add", methods=["POST"]) 1845def api_gallery_add_user(id): 1846gallery = db.session.get(Gallery, id) 1847if gallery is None: 1848flask.abort(404) 1849 1850current_user = db.session.get(User, flask.session.get("username")) 1851if current_user is None: 1852flask.abort(401) 1853 1854if current_user != gallery.owner and not current_user.admin: 1855flask.abort(403) 1856 1857username = flask.request.json.get("username") 1858if username == gallery.owner_name: 1859return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400 1860 1861user = db.session.get(User, username) 1862if user is None: 1863return flask.jsonify({"error": "User not found"}), 404 1864 1865if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1866return flask.jsonify({"error": "User is already in the gallery"}), 400 1867 1868db.session.add(UserInGallery(user, gallery)) 1869 1870db.session.commit() 1871 1872return flask.jsonify({"message": "User added to gallery"}) 1873 1874 1875@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"]) 1876def api_gallery_remove_user(id): 1877gallery = db.session.get(Gallery, id) 1878if gallery is None: 1879flask.abort(404) 1880 1881current_user = db.session.get(User, flask.session.get("username")) 1882if current_user is None: 1883flask.abort(401) 1884 1885if current_user != gallery.owner and not current_user.admin: 1886flask.abort(403) 1887 1888username = flask.request.json.get("username") 1889user = db.session.get(User, username) 1890if user is None: 1891return flask.jsonify({"error": "User not found"}), 404 1892 1893user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1894if user_in_gallery is None: 1895return flask.jsonify({"error": "User is not in the gallery"}), 400 1896 1897db.session.delete(user_in_gallery) 1898 1899db.session.commit() 1900 1901return flask.jsonify({"message": "User removed from gallery"}) 1902 1903 1904@app.route("/api/gallery/<int:id>/delete", methods=["POST"]) 1905def api_delete_gallery(id): 1906gallery = db.session.get(Gallery, id) 1907if gallery is None: 1908flask.abort(404) 1909 1910current_user = db.session.get(User, flask.session.get("username")) 1911if current_user is None: 1912flask.abort(401) 1913 1914if current_user != gallery.owner and not current_user.admin: 1915flask.abort(403) 1916 1917PictureInGallery.query.filter_by(gallery=gallery).delete() 1918UserInGallery.query.filter_by(gallery=gallery).delete() 1919db.session.delete(gallery) 1920 1921db.session.commit() 1922 1923return flask.jsonify({"message": "Gallery deleted"}) 1924 1925