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