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