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