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("/picture/<int:id>/put-file") 908def put_file(id): 909resource = db.session.get(PictureResource, id) 910if resource is None: 911flask.abort(404) 912 913current_user = db.session.get(User, flask.session.get("username")) 914if current_user is None: 915flask.abort(401) 916 917if resource.author != current_user and not current_user.admin: 918flask.abort(403) 919 920return flask.render_template("put-file.html", resource=resource) 921 922 923@app.route("/picture/<int:id>/put-file", methods=["POST"]) 924def put_file_post(id): 925resource = db.session.get(PictureResource, id) 926if resource is None: 927flask.abort(404) 928 929current_user = db.session.get(User, flask.session.get("username")) 930if current_user is None: 931flask.abort(401) 932 933if resource.author != current_user and not current_user.admin: 934flask.abort(403) 935 936file = flask.request.files["file"] 937 938if not file or not file.filename: 939flask.flash("Select a file") 940return flask.redirect(flask.request.url) 941 942if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 943flask.flash("Only images are supported") 944return flask.redirect(flask.request.url) 945 946file_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 947print(file_path) 948file.save(file_path) 949pil_image = Image.open(file_path) 950resource.width, resource.height = pil_image.size 951pil_image = make_thumbnail(pil_image) 952pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 953 954return flask.redirect("/picture/" + str(resource.id)) 955 956 957@app.route("/gallery/<int:id>/") 958def gallery(id): 959gallery = db.session.get(Gallery, id) 960if gallery is None: 961flask.abort(404) 962 963current_user = db.session.get(User, flask.session.get("username")) 964 965have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 966have_extended_permission = current_user and (current_user == gallery.owner or current_user.admin) 967 968return flask.render_template("gallery.html", gallery=gallery, 969have_permission=have_permission, 970have_extended_permission=have_extended_permission) 971 972 973@app.route("/create-gallery") 974def create_gallery(): 975if "username" not in flask.session: 976flask.flash("Log in to create galleries.") 977return flask.redirect("/accounts") 978 979return flask.render_template("create-gallery.html") 980 981 982@app.route("/create-gallery", methods=["POST"]) 983def create_gallery_post(): 984if not flask.session.get("username"): 985flask.abort(401) 986 987if not flask.request.form.get("title"): 988flask.flash("Enter a title") 989return flask.redirect(flask.request.url) 990 991description = flask.request.form.get("description", "") 992 993gallery = Gallery(flask.request.form["title"], description, 994db.session.get(User, flask.session["username"])) 995db.session.add(gallery) 996db.session.commit() 997 998return flask.redirect("/gallery/" + str(gallery.id)) 999 1000 1001@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 1002def gallery_add_picture(id): 1003gallery = db.session.get(Gallery, id) 1004if gallery is None: 1005flask.abort(404) 1006 1007if "username" not in flask.session: 1008flask.abort(401) 1009 1010if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1011flask.abort(403) 1012 1013picture_id = flask.request.form.get("picture_id") 1014if "/" in picture_id: # also allow full URLs 1015picture_id = picture_id.rstrip("/").rpartition("/")[1] 1016if not picture_id: 1017flask.flash("Select a picture") 1018return flask.redirect("/gallery/" + str(gallery.id)) 1019picture_id = int(picture_id) 1020 1021picture = db.session.get(PictureResource, picture_id) 1022if picture is None: 1023flask.flash("Invalid picture") 1024return flask.redirect("/gallery/" + str(gallery.id)) 1025 1026if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1027flask.flash("This picture is already in the gallery") 1028return flask.redirect("/gallery/" + str(gallery.id)) 1029 1030db.session.add(PictureInGallery(picture, gallery)) 1031 1032db.session.commit() 1033 1034return flask.redirect("/gallery/" + str(gallery.id)) 1035 1036 1037@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 1038def gallery_remove_picture(id): 1039gallery = db.session.get(Gallery, id) 1040if gallery is None: 1041flask.abort(404) 1042 1043if "username" not in flask.session: 1044flask.abort(401) 1045 1046current_user = db.session.get(User, flask.session.get("username")) 1047 1048if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1049flask.abort(403) 1050 1051picture_id = int(flask.request.form.get("picture_id")) 1052 1053picture = db.session.get(PictureResource, picture_id) 1054if picture is None: 1055flask.flash("Invalid picture") 1056return flask.redirect("/gallery/" + str(gallery.id)) 1057 1058picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 1059gallery=gallery).first() 1060if picture_in_gallery is None: 1061flask.flash("This picture isn't in the gallery") 1062return flask.redirect("/gallery/" + str(gallery.id)) 1063 1064db.session.delete(picture_in_gallery) 1065 1066db.session.commit() 1067 1068return flask.redirect("/gallery/" + str(gallery.id)) 1069 1070 1071@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 1072def gallery_add_from_query(id): 1073gallery = db.session.get(Gallery, id) 1074if gallery is None: 1075flask.abort(404) 1076 1077if "username" not in flask.session: 1078flask.abort(401) 1079 1080if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1081flask.abort(403) 1082 1083query_yaml = flask.request.form.get("query", "") 1084 1085yaml_parser = yaml.YAML() 1086query_data = yaml_parser.load(query_yaml) or {} 1087query = get_picture_query(query_data) 1088 1089pictures = query.all() 1090 1091count = 0 1092 1093for picture in pictures: 1094if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1095db.session.add(PictureInGallery(picture, gallery)) 1096count += 1 1097 1098db.session.commit() 1099 1100flask.flash(f"Added {count} pictures to the gallery") 1101 1102return flask.redirect("/gallery/" + str(gallery.id)) 1103 1104 1105@app.route("/gallery/<int:id>/users") 1106def gallery_users(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")) 1112have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1113 1114return flask.render_template("gallery-users.html", gallery=gallery, 1115have_permission=have_permission) 1116 1117 1118@app.route("/gallery/<int:id>/edit") 1119def edit_gallery(id): 1120gallery = db.session.get(Gallery, id) 1121if gallery is None: 1122flask.abort(404) 1123 1124current_user = db.session.get(User, flask.session.get("username")) 1125if current_user is None: 1126flask.abort(401) 1127 1128if current_user != gallery.owner and not current_user.admin: 1129flask.abort(403) 1130 1131return flask.render_template("edit-gallery.html", gallery=gallery) 1132 1133 1134@app.route("/gallery/<int:id>/edit", methods=["POST"]) 1135def edit_gallery_post(id): 1136gallery = db.session.get(Gallery, id) 1137if gallery is None: 1138flask.abort(404) 1139 1140current_user = db.session.get(User, flask.session.get("username")) 1141if current_user is None: 1142flask.abort(401) 1143 1144if current_user != gallery.owner and not current_user.admin: 1145flask.abort(403) 1146 1147title = flask.request.form["title"] 1148description = flask.request.form.get("description") 1149 1150if not title: 1151flask.flash("Enter a title") 1152return flask.redirect(flask.request.url) 1153 1154if not description: 1155description = "" 1156 1157gallery.title = title 1158gallery.description = description 1159 1160db.session.commit() 1161 1162return flask.redirect("/gallery/" + str(gallery.id)) 1163 1164 1165@app.route("/gallery/<int:id>/delete") 1166def delete_gallery(id): 1167gallery = db.session.get(Gallery, id) 1168if gallery is None: 1169flask.abort(404) 1170 1171current_user = db.session.get(User, flask.session.get("username")) 1172if current_user is None: 1173flask.abort(401) 1174 1175if current_user != gallery.owner and not current_user.admin: 1176flask.abort(403) 1177 1178PictureInGallery.query.filter_by(gallery=gallery).delete() 1179UserInGallery.query.filter_by(gallery=gallery).delete() 1180db.session.delete(gallery) 1181db.session.commit() 1182 1183return flask.redirect("/") 1184 1185 1186@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1187def gallery_add_user(id): 1188gallery = db.session.get(Gallery, id) 1189if gallery is None: 1190flask.abort(404) 1191 1192current_user = db.session.get(User, flask.session.get("username")) 1193if current_user is None: 1194flask.abort(401) 1195 1196if current_user != gallery.owner and not current_user.admin: 1197flask.abort(403) 1198 1199username = flask.request.form.get("username") 1200if username == gallery.owner_name: 1201flask.flash("The owner is already in the gallery") 1202return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1203 1204user = db.session.get(User, username) 1205if user is None: 1206flask.flash("User not found") 1207return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1208 1209if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1210flask.flash("User is already in the gallery") 1211return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1212 1213db.session.add(UserInGallery(user, gallery)) 1214 1215db.session.commit() 1216 1217return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1218 1219 1220@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1221def gallery_remove_user(id): 1222gallery = db.session.get(Gallery, id) 1223if gallery is None: 1224flask.abort(404) 1225 1226current_user = db.session.get(User, flask.session.get("username")) 1227if current_user is None: 1228flask.abort(401) 1229 1230if current_user != gallery.owner and not current_user.admin: 1231flask.abort(403) 1232 1233username = flask.request.form.get("username") 1234user = db.session.get(User, username) 1235if user is None: 1236flask.flash("User not found") 1237return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1238 1239user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1240if user_in_gallery is None: 1241flask.flash("User is not in the gallery") 1242return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1243 1244db.session.delete(user_in_gallery) 1245 1246db.session.commit() 1247 1248return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1249 1250 1251class APIError(Exception): 1252def __init__(self, status_code, message): 1253self.status_code = status_code 1254self.message = message 1255 1256 1257def get_picture_query(query_data): 1258query = db.session.query(PictureResource) 1259 1260def has_condition(id): 1261descendants_cte = ( 1262db.select(PictureObject.id) 1263.where(PictureObject.id == id) 1264.cte(name=f"descendants_cte_{id}", recursive=True) 1265) 1266 1267descendants_cte = descendants_cte.union_all( 1268db.select(PictureObjectInheritance.child_id) 1269.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 1270) 1271 1272return PictureResource.regions.any( 1273PictureRegion.object_id.in_( 1274db.select(descendants_cte.c.id) 1275) 1276) 1277 1278requirement_conditions = { 1279# Has an object with the ID in the given list 1280"has_object": lambda value: PictureResource.regions.any( 1281PictureRegion.object_id.in_(value)), 1282# Has an object with the ID in the given list, or a subtype of it 1283"has": lambda value: db.or_(*[has_condition(id) for id in value]), 1284"nature": lambda value: PictureResource.nature_id.in_(value), 1285"licence": lambda value: PictureResource.licences.any( 1286PictureLicence.licence_id.in_(value)), 1287"author": lambda value: PictureResource.author_name.in_(value), 1288"title": lambda value: PictureResource.title.ilike(value), 1289"description": lambda value: PictureResource.description.ilike(value), 1290"origin_url": lambda value: db.func.lower(db.func.substr( 1291PictureResource.origin_url, 1292db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1293)).in_(value), 1294"above_width": lambda value: PictureResource.width >= value, 1295"below_width": lambda value: PictureResource.width <= value, 1296"above_height": lambda value: PictureResource.height >= value, 1297"below_height": lambda value: PictureResource.height <= value, 1298"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1299value), 1300"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1301value), 1302"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1303"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value, 1304"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value, 1305"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1306"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1307"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1308"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1309"copied_from": lambda value: PictureResource.copied_from_id.in_(value), 1310} 1311 1312if "want" in query_data: 1313for i in query_data["want"]: 1314if len(i) != 1: 1315raise APIError(400, "Each requirement must have exactly one key") 1316requirement, value = list(i.items())[0] 1317if requirement not in requirement_conditions: 1318raise APIError(400, f"Unknown requirement type: {requirement}") 1319 1320condition = requirement_conditions[requirement] 1321query = query.filter(condition(value)) 1322if "exclude" in query_data: 1323for i in query_data["exclude"]: 1324if len(i) != 1: 1325raise APIError(400, "Each exclusion must have exactly one key") 1326requirement, value = list(i.items())[0] 1327if requirement not in requirement_conditions: 1328raise APIError(400, f"Unknown requirement type: {requirement}") 1329 1330condition = requirement_conditions[requirement] 1331query = query.filter(~condition(value)) 1332if not query_data.get("include_obsolete", False): 1333query = query.filter(PictureResource.replaced_by_id.is_(None)) 1334 1335return query 1336 1337 1338@app.route("/query-pictures") 1339def graphical_query_pictures(): 1340return flask.render_template("graphical-query-pictures.html") 1341 1342 1343@app.route("/query-pictures-results") 1344def graphical_query_pictures_results(): 1345query_yaml = flask.request.args.get("query", "") 1346yaml_parser = yaml.YAML() 1347query_data = yaml_parser.load(query_yaml) or {} 1348try: 1349query = get_picture_query(query_data) 1350except APIError as e: 1351flask.abort(e.status_code) 1352 1353page = int(flask.request.args.get("page", 1)) 1354per_page = int(flask.request.args.get("per_page", 16)) 1355 1356resources = query.paginate(page=page, per_page=per_page) 1357 1358return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1359query=query_yaml, 1360page_number=page, page_length=per_page, 1361num_pages=resources.pages, 1362prev_page=resources.prev_num, next_page=resources.next_num) 1363 1364 1365@app.route("/raw/picture/<int:id>") 1366def raw_picture(id): 1367resource = db.session.get(PictureResource, id) 1368if resource is None: 1369flask.abort(404) 1370 1371response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1372str(resource.id)) 1373response.mimetype = resource.file_format 1374 1375return response 1376 1377 1378@app.route("/raw/picture-thumbnail/<int:id>") 1379def raw_picture_thumbnail(id): 1380resource = db.session.get(PictureResource, id) 1381if resource is None: 1382flask.abort(404) 1383 1384if not path.exists(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id))): 1385pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1386pil_image = make_thumbnail(pil_image) 1387pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1388 1389response = flask.send_from_directory(path.join(config.DATA_PATH, "picture-thumbnails"), 1390str(resource.id)) 1391response.mimetype = resource.file_format 1392 1393return response 1394 1395 1396@app.route("/object/") 1397def graphical_object_types(): 1398return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1399 1400 1401@app.route("/api/object-types") 1402def object_types(): 1403objects = db.session.query(PictureObject).all() 1404return flask.jsonify({object.id: object.description for object in objects}) 1405 1406 1407@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1408def query_pictures(): 1409offset = int(flask.request.args.get("offset", 0)) 1410limit = int(flask.request.args.get("limit", 16)) 1411ordering = flask.request.args.get("ordering", "date-desc") 1412 1413yaml_parser = yaml.YAML() 1414query_data = yaml_parser.load(flask.request.data) or {} 1415try: 1416query = get_picture_query(query_data) 1417except APIError as e: 1418return flask.jsonify({"error": e.message}), e.status_code 1419 1420rating_count_subquery = db.select(db.func.count(PictureRating.id)).where( 1421PictureRating.resource_id == PictureResource.id).scalar_subquery() 1422region_count_subquery = db.select(db.func.count(PictureRegion.id)).where( 1423PictureRegion.resource_id == PictureResource.id).scalar_subquery() 1424rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where( 1425PictureRating.resource_id == PictureResource.id).scalar_subquery() 1426 1427match ordering: 1428case "date-desc": 1429query = query.order_by(PictureResource.timestamp.desc()) 1430case "date-asc": 1431query = query.order_by(PictureResource.timestamp.asc()) 1432case "title-asc": 1433query = query.order_by(PictureResource.title.asc()) 1434case "title-desc": 1435query = query.order_by(PictureResource.title.desc()) 1436case "random": 1437query = query.order_by(db.func.random()) 1438case "number-regions-desc": 1439query = query.order_by(region_count_subquery.desc()) 1440case "number-regions-asc": 1441query = query.order_by(region_count_subquery.asc()) 1442case "rating-desc": 1443query = query.order_by(rating_subquery.desc()) 1444case "rating-asc": 1445query = query.order_by(rating_subquery.asc()) 1446case "number-ratings-desc": 1447query = query.order_by(rating_count_subquery.desc()) 1448case "number-ratings-asc": 1449query = query.order_by(rating_count_subquery.asc()) 1450 1451query = query.offset(offset).limit(limit) 1452resources = query.all() 1453 1454json_response = { 1455"date_generated": datetime.utcnow().timestamp(), 1456"resources": [], 1457"offset": offset, 1458"limit": limit, 1459} 1460 1461json_resources = json_response["resources"] 1462 1463for resource in resources: 1464json_resource = { 1465"id": resource.id, 1466"title": resource.title, 1467"description": resource.description, 1468"timestamp": resource.timestamp.timestamp(), 1469"origin_url": resource.origin_url, 1470"author": resource.author_name, 1471"file_format": resource.file_format, 1472"width": resource.width, 1473"height": resource.height, 1474"nature": resource.nature_id, 1475"licences": [licence.licence_id for licence in resource.licences], 1476"replaces": resource.replaces_id, 1477"replaced_by": resource.replaced_by_id, 1478"regions": [], 1479"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1480} 1481for region in resource.regions: 1482json_resource["regions"].append({ 1483"object": region.object_id, 1484"type": region.json["type"], 1485"shape": region.json["shape"], 1486}) 1487 1488json_resources.append(json_resource) 1489 1490return flask.jsonify(json_response) 1491 1492 1493@app.route("/api/picture/<int:id>/") 1494def api_picture(id): 1495resource = db.session.get(PictureResource, id) 1496if resource is None: 1497flask.abort(404) 1498 1499json_resource = { 1500"id": resource.id, 1501"title": resource.title, 1502"description": resource.description, 1503"timestamp": resource.timestamp.timestamp(), 1504"origin_url": resource.origin_url, 1505"author": resource.author_name, 1506"file_format": resource.file_format, 1507"width": resource.width, 1508"height": resource.height, 1509"nature": resource.nature_id, 1510"licences": [licence.licence_id for licence in resource.licences], 1511"replaces": resource.replaces_id, 1512"replaced_by": resource.replaced_by_id, 1513"regions": [], 1514"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1515"rating_average": resource.average_rating, 1516"rating_count": resource.rating_totals, 1517} 1518for region in resource.regions: 1519json_resource["regions"].append({ 1520"object": region.object_id, 1521"type": region.json["type"], 1522"shape": region.json["shape"], 1523}) 1524 1525return flask.jsonify(json_resource) 1526 1527 1528@app.route("/api/licence/") 1529def api_licences(): 1530licences = db.session.query(Licence).all() 1531json_licences = { 1532licence.id: { 1533"title": licence.title, 1534"free": licence.free, 1535"pinned": licence.pinned, 1536} for licence in licences 1537} 1538 1539return flask.jsonify(json_licences) 1540 1541 1542@app.route("/api/licence/<id>/") 1543def api_licence(id): 1544licence = db.session.get(Licence, id) 1545if licence is None: 1546flask.abort(404) 1547 1548json_licence = { 1549"id": licence.id, 1550"title": licence.title, 1551"description": licence.description, 1552"info_url": licence.info_url, 1553"legalese_url": licence.url, 1554"free": licence.free, 1555"logo_url": licence.logo_url, 1556"pinned": licence.pinned, 1557} 1558 1559return flask.jsonify(json_licence) 1560 1561 1562@app.route("/api/nature/") 1563def api_natures(): 1564natures = db.session.query(PictureNature).all() 1565json_natures = { 1566nature.id: nature.description for nature in natures 1567} 1568 1569return flask.jsonify(json_natures) 1570 1571 1572@app.route("/api/user/") 1573def api_users(): 1574offset = int(flask.request.args.get("offset", 0)) 1575limit = int(flask.request.args.get("limit", 16)) 1576 1577users = db.session.query(User).offset(offset).limit(limit).all() 1578 1579json_users = { 1580user.username: { 1581"admin": user.admin, 1582} for user in users 1583} 1584 1585return flask.jsonify(json_users) 1586 1587 1588@app.route("/api/user/<username>/") 1589def api_user(username): 1590user = db.session.get(User, username) 1591if user is None: 1592flask.abort(404) 1593 1594json_user = { 1595"username": user.username, 1596"admin": user.admin, 1597"joined": user.joined_timestamp.timestamp(), 1598} 1599 1600return flask.jsonify(json_user) 1601 1602 1603@app.route("/api/login", methods=["POST"]) 1604def api_login(): 1605username = flask.request.json["username"] 1606password = flask.request.json["password"] 1607 1608user = db.session.get(User, username) 1609 1610if user is None: 1611return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1612 1613if not bcrypt.check_password_hash(user.password_hashed, password): 1614return flask.jsonify({"error": "Incorrect password"}), 401 1615 1616flask.session["username"] = username 1617 1618return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1619 1620 1621@app.route("/api/logout", methods=["POST"]) 1622def api_logout(): 1623flask.session.pop("username", None) 1624return flask.jsonify({"message": "You have been logged out."}) 1625 1626 1627@app.route("/api/upload", methods=["POST"]) 1628def api_upload(): 1629if "username" not in flask.session: 1630return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1631 1632json_ = json.loads(flask.request.form["json"]) 1633title = json_["title"] 1634description = json_.get("description", "") 1635origin_url = json_.get("origin_url", "") 1636author = db.session.get(User, flask.session["username"]) 1637licence_ids = json_["licence"] 1638nature_id = json_["nature"] 1639file = flask.request.files["file"] 1640 1641if not file or not file.filename: 1642return flask.jsonify({"error": "An image file must be uploaded"}), 400 1643 1644if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1645return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1646 1647if not title: 1648return flask.jsonify({"error": "Give a title"}), 400 1649 1650if not description: 1651description = "" 1652 1653if not nature_id: 1654return flask.jsonify({"error": "Give a picture type"}), 400 1655 1656if not licence_ids: 1657return flask.jsonify({"error": "Give licences"}), 400 1658 1659licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1660if not any(licence.free for licence in licences): 1661return flask.jsonify({"error": "Use at least one free licence"}), 400 1662 1663resource = PictureResource(title, author, description, origin_url, licence_ids, 1664file.mimetype, 1665db.session.get(PictureNature, nature_id)) 1666db.session.add(resource) 1667db.session.commit() 1668file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1669pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1670resource.width, resource.height = pil_image.size 1671pil_image.thumbnail(config.THUMBNAIL_SIZE) 1672pil_image = pil_image.convert("RGB") 1673pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1674db.session.commit() 1675 1676if json_.get("annotations"): 1677try: 1678resource.put_annotations(json_["annotations"]) 1679db.session.commit() 1680except json.JSONDecodeError: 1681return flask.jsonify({"error": "Invalid annotations"}), 400 1682 1683return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id}) 1684 1685 1686@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1687def api_update_picture(id): 1688resource = db.session.get(PictureResource, id) 1689if resource is None: 1690return flask.jsonify({"error": "Picture not found"}), 404 1691current_user = db.session.get(User, flask.session.get("username")) 1692if current_user is None: 1693return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1694if resource.author != current_user and not current_user.admin: 1695return flask.jsonify({"error": "You are not the author of this picture"}), 403 1696 1697title = flask.request.json.get("title", resource.title) 1698description = flask.request.json.get("description", resource.description) 1699origin_url = flask.request.json.get("origin_url", resource.origin_url) 1700licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1701nature_id = flask.request.json.get("nature", resource.nature_id) 1702 1703if not title: 1704return flask.jsonify({"error": "Give a title"}), 400 1705 1706if not description: 1707description = "" 1708 1709if not nature_id: 1710return flask.jsonify({"error": "Give a picture type"}), 400 1711 1712if not licence_ids: 1713return flask.jsonify({"error": "Give licences"}), 400 1714 1715licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1716 1717if not any(licence.free for licence in licences): 1718return flask.jsonify({"error": "Use at least one free licence"}), 400 1719 1720resource.title = title 1721resource.description = description 1722resource.origin_url = origin_url 1723resource.licences = licences 1724resource.nature = db.session.get(PictureNature, nature_id) 1725 1726db.session.commit() 1727 1728return flask.jsonify({"message": "Picture updated successfully"}) 1729 1730 1731@app.route("/api/picture/<int:id>/delete", methods=["POST"]) 1732def api_delete_picture(id): 1733resource = db.session.get(PictureResource, id) 1734if resource is None: 1735return flask.jsonify({"error": "Picture not found"}), 404 1736current_user = db.session.get(User, flask.session.get("username")) 1737if current_user is None: 1738return flask.jsonify({"error": "You must be logged in to delete pictures"}), 401 1739if resource.author != current_user and not current_user.admin: 1740return flask.jsonify({"error": "You are not the author of this picture"}), 403 1741 1742PictureInGallery.query.filter_by(resource=resource).delete() 1743db.session.delete(resource) 1744db.session.commit() 1745 1746return flask.jsonify({"message": "Picture deleted successfully"}) 1747 1748 1749@app.route("/api/picture/<int:id>/put-file", methods=["POST"]) 1750def api_put_file(id): 1751resource = db.session.get(PictureResource, id) 1752if resource is None: 1753return flask.jsonify({"error": "Picture not found"}), 404 1754current_user = db.session.get(User, flask.session.get("username")) 1755if current_user is None: 1756return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1757if resource.author != current_user and not current_user.admin: 1758return flask.jsonify({"error": "You are not the author of this picture"}), 403 1759 1760file = flask.request.files["file"] 1761 1762if not file or not file.filename: 1763return flask.jsonify({"error": "Provide a file"}), 400 1764 1765if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1766return flask.jsonify({"error": "Only images are supported"}), 400 1767 1768file_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 1769file.save(file_path) 1770pil_image = Image.open(file_path) 1771resource.width, resource.height = pil_image.size 1772pil_image = make_thumbnail(pil_image) 1773pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1774 1775return flask.jsonify({"message": "File uploaded successfully"}) 1776 1777 1778@app.route("/api/picture/<int:id>/rate", methods=["POST"]) 1779def api_rate_picture(id): 1780resource = db.session.get(PictureResource, id) 1781if resource is None: 1782flask.abort(404) 1783 1784current_user = db.session.get(User, flask.session.get("username")) 1785if current_user is None: 1786flask.abort(401) 1787 1788rating = int(flask.request.json.get("rating", 0)) 1789 1790if not rating: 1791# Delete the existing rating 1792if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1793db.session.delete(PictureRating.query.filter_by(resource=resource, 1794user=current_user).first()) 1795db.session.commit() 1796 1797return flask.jsonify({"message": "Existing rating removed"}) 1798 1799if not 1 <= rating <= 5: 1800flask.flash("Invalid rating") 1801return flask.jsonify({"error": "Invalid rating"}), 400 1802 1803if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1804PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 1805else: 1806# Create a new rating 1807db.session.add(PictureRating(resource, current_user, rating)) 1808 1809db.session.commit() 1810 1811return flask.jsonify({"message": "Rating saved"}) 1812 1813 1814@app.route("/api/gallery/<int:id>/") 1815def api_gallery(id): 1816gallery = db.session.get(Gallery, id) 1817if gallery is None: 1818flask.abort(404) 1819 1820json_gallery = { 1821"id": gallery.id, 1822"title": gallery.title, 1823"description": gallery.description, 1824"owner": gallery.owner_name, 1825"users": [user.username for user in gallery.users], 1826} 1827 1828return flask.jsonify(json_gallery) 1829 1830 1831@app.route("/api/gallery/<int:id>/edit", methods=["POST"]) 1832def api_edit_gallery(id): 1833gallery = db.session.get(Gallery, id) 1834if gallery is None: 1835flask.abort(404) 1836 1837current_user = db.session.get(User, flask.session.get("username")) 1838if current_user is None: 1839flask.abort(401) 1840 1841if current_user != gallery.owner and not current_user.admin: 1842flask.abort(403) 1843 1844title = flask.request.json.get("title", gallery.title) 1845description = flask.request.json.get("description", gallery.description) 1846 1847if not title: 1848return flask.jsonify({"error": "Give a title"}), 400 1849 1850if not description: 1851description = "" 1852 1853gallery.title = title 1854gallery.description = description 1855 1856db.session.commit() 1857 1858return flask.jsonify({"message": "Gallery updated successfully"}) 1859 1860 1861@app.route("/api/new-gallery", methods=["POST"]) 1862def api_new_gallery(): 1863if "username" not in flask.session: 1864return flask.jsonify({"error": "You must be logged in to create galleries"}), 401 1865 1866title = flask.request.json.get("title") 1867description = flask.request.json.get("description", "") 1868 1869if not title: 1870return flask.jsonify({"error": "Give a title"}), 400 1871 1872gallery = Gallery(title, description, db.session.get(User, flask.session["username"])) 1873db.session.add(gallery) 1874db.session.commit() 1875 1876return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id}) 1877 1878 1879@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"]) 1880def api_gallery_add_picture(id): 1881gallery = db.session.get(Gallery, id) 1882if gallery is None: 1883flask.abort(404) 1884 1885if "username" not in flask.session: 1886return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401 1887 1888current_user = db.session.get(User, flask.session.get("username")) 1889 1890if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1891return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403 1892 1893picture_id = flask.request.json.get("picture_id") 1894 1895try: 1896picture_id = int(picture_id) 1897except ValueError: 1898return flask.jsonify({"error": "Invalid picture ID"}), 400 1899 1900picture = db.session.get(PictureResource, picture_id) 1901if picture is None: 1902return flask.jsonify({"error": "The picture doesn't exist"}), 404 1903 1904if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1905return flask.jsonify({"error": "This picture is already in the gallery"}), 400 1906 1907db.session.add(PictureInGallery(picture, gallery)) 1908 1909db.session.commit() 1910 1911return flask.jsonify({"message": "Picture added to gallery"}) 1912 1913 1914@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"]) 1915def api_gallery_remove_picture(id): 1916gallery = db.session.get(Gallery, id) 1917if gallery is None: 1918flask.abort(404) 1919 1920if "username" not in flask.session: 1921return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401 1922 1923current_user = db.session.get(User, flask.session.get("username")) 1924 1925if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1926return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403 1927 1928picture_id = flask.request.json.get("picture_id") 1929 1930try: 1931picture_id = int(picture_id) 1932except ValueError: 1933return flask.jsonify({"error": "Invalid picture ID"}), 400 1934 1935picture = db.session.get(PictureResource, picture_id) 1936if picture is None: 1937return flask.jsonify({"error": "The picture doesn't exist"}), 404 1938 1939picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 1940if picture_in_gallery is None: 1941return flask.jsonify({"error": "This picture isn't in the gallery"}), 400 1942 1943db.session.delete(picture_in_gallery) 1944 1945db.session.commit() 1946 1947return flask.jsonify({"message": "Picture removed from gallery"}) 1948 1949 1950@app.route("/api/gallery/<int:id>/users/add", methods=["POST"]) 1951def api_gallery_add_user(id): 1952gallery = db.session.get(Gallery, id) 1953if gallery is None: 1954flask.abort(404) 1955 1956current_user = db.session.get(User, flask.session.get("username")) 1957if current_user is None: 1958flask.abort(401) 1959 1960if current_user != gallery.owner and not current_user.admin: 1961flask.abort(403) 1962 1963username = flask.request.json.get("username") 1964if username == gallery.owner_name: 1965return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400 1966 1967user = db.session.get(User, username) 1968if user is None: 1969return flask.jsonify({"error": "User not found"}), 404 1970 1971if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1972return flask.jsonify({"error": "User is already in the gallery"}), 400 1973 1974db.session.add(UserInGallery(user, gallery)) 1975 1976db.session.commit() 1977 1978return flask.jsonify({"message": "User added to gallery"}) 1979 1980 1981@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"]) 1982def api_gallery_remove_user(id): 1983gallery = db.session.get(Gallery, id) 1984if gallery is None: 1985flask.abort(404) 1986 1987current_user = db.session.get(User, flask.session.get("username")) 1988if current_user is None: 1989flask.abort(401) 1990 1991if current_user != gallery.owner and not current_user.admin: 1992flask.abort(403) 1993 1994username = flask.request.json.get("username") 1995user = db.session.get(User, username) 1996if user is None: 1997return flask.jsonify({"error": "User not found"}), 404 1998 1999user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 2000if user_in_gallery is None: 2001return flask.jsonify({"error": "User is not in the gallery"}), 400 2002 2003db.session.delete(user_in_gallery) 2004 2005db.session.commit() 2006 2007return flask.jsonify({"message": "User removed from gallery"}) 2008 2009 2010@app.route("/api/gallery/<int:id>/delete", methods=["POST"]) 2011def api_delete_gallery(id): 2012gallery = db.session.get(Gallery, id) 2013if gallery is None: 2014flask.abort(404) 2015 2016current_user = db.session.get(User, flask.session.get("username")) 2017if current_user is None: 2018flask.abort(401) 2019 2020if current_user != gallery.owner and not current_user.admin: 2021flask.abort(403) 2022 2023PictureInGallery.query.filter_by(gallery=gallery).delete() 2024UserInGallery.query.filter_by(gallery=gallery).delete() 2025db.session.delete(gallery) 2026 2027db.session.commit() 2028 2029return flask.jsonify({"message": "Gallery deleted"}) 2030 2031