app.py
Python script, ASCII text executable
1import json 2from datetime import datetime 3from email.policy import default 4from time import perf_counter 5 6import flask 7from flask_sqlalchemy import SQLAlchemy 8from flask_bcrypt import Bcrypt 9from flask_httpauth import HTTPBasicAuth 10from markupsafe import escape, Markup 11from flask_migrate import Migrate, current 12from jinja2_fragments.flask import render_block 13from sqlalchemy import false 14from sqlalchemy.orm import backref 15import sqlalchemy.dialects.postgresql 16from os import path 17import os 18from urllib.parse import urlencode 19import mimetypes 20import ruamel.yaml as yaml 21 22from PIL import Image 23from sqlalchemy.orm.persistence import post_update 24from sqlalchemy.sql.functions import current_user 25 26import config 27import markdown 28 29app = flask.Flask(__name__) 30bcrypt = Bcrypt(app) 31 32app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 33app.config["SECRET_KEY"] = config.DB_PASSWORD 34 35db = SQLAlchemy(app) 36migrate = Migrate(app, db) 37 38 39@app.template_filter("split") 40def split(value, separator=None, maxsplit=-1): 41return value.split(separator, maxsplit) 42 43 44@app.template_filter("median") 45def median(value): 46value = list(value) # prevent generators 47return sorted(value)[len(value) // 2] 48 49 50@app.template_filter("set") 51def set_filter(value): 52return set(value) 53 54 55@app.template_global() 56def modify_query(**new_values): 57args = flask.request.args.copy() 58for key, value in new_values.items(): 59args[key] = value 60 61return f"{flask.request.path}?{urlencode(args)}" 62 63 64@app.context_processor 65def default_variables(): 66return { 67"current_user": db.session.get(User, flask.session.get("username")), 68} 69 70 71with app.app_context(): 72class User(db.Model): 73username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 74password_hashed = db.Column(db.String(60), nullable=False) 75admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 76pictures = db.relationship("PictureResource", back_populates="author") 77joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 78galleries = db.relationship("Gallery", back_populates="owner") 79galleries_joined = db.relationship("UserInGallery", back_populates="user") 80ratings = db.relationship("PictureRating", back_populates="user") 81 82def __init__(self, username, password): 83self.username = username 84self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 85 86@property 87def formatted_name(self): 88if self.admin: 89return self.username + "*" 90return self.username 91 92 93class Licence(db.Model): 94id = db.Column(db.String(64), primary_key=True) # SPDX identifier 95title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 96description = db.Column(db.UnicodeText, 97nullable=False) # brief description of its permissions and restrictions 98info_url = db.Column(db.String(1024), 99nullable=False) # the URL to a page with general information about the licence 100url = db.Column(db.String(1024), 101nullable=True) # the URL to a page with the full text of the licence and more information 102pictures = db.relationship("PictureLicence", back_populates="licence") 103free = db.Column(db.Boolean, nullable=False, 104default=False) # whether the licence is free or not 105logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 106pinned = db.Column(db.Boolean, nullable=False, 107default=False) # whether the licence should be shown at the top of the list 108 109def __init__(self, id, title, description, info_url, url, free, logo_url=None, 110pinned=False): 111self.id = id 112self.title = title 113self.description = description 114self.info_url = info_url 115self.url = url 116self.free = free 117self.logo_url = logo_url 118self.pinned = pinned 119 120 121class PictureLicence(db.Model): 122id = db.Column(db.Integer, primary_key=True, autoincrement=True) 123 124resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 125licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 126 127resource = db.relationship("PictureResource", back_populates="licences") 128licence = db.relationship("Licence", back_populates="pictures") 129 130def __init__(self, resource, licence): 131self.resource = resource 132self.licence = licence 133 134 135class Resource(db.Model): 136__abstract__ = True 137 138id = db.Column(db.Integer, primary_key=True, autoincrement=True) 139title = db.Column(db.UnicodeText, nullable=False) 140description = db.Column(db.UnicodeText, nullable=False) 141timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 142origin_url = db.Column(db.String(2048), 143nullable=True) # should be left empty if it's original or the source is unknown but public domain 144 145 146class PictureNature(db.Model): 147# Examples: 148# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 149# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 150# "screen-photo", "pattern", "collage", "ai", and so on 151id = db.Column(db.String(64), primary_key=True) 152description = db.Column(db.UnicodeText, nullable=False) 153resources = db.relationship("PictureResource", back_populates="nature") 154 155def __init__(self, id, description): 156self.id = id 157self.description = description 158 159 160class PictureObjectInheritance(db.Model): 161parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 162primary_key=True) 163child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 164primary_key=True) 165 166parent = db.relationship("PictureObject", foreign_keys=[parent_id], 167back_populates="child_links") 168child = db.relationship("PictureObject", foreign_keys=[child_id], 169back_populates="parent_links") 170 171def __init__(self, parent, child): 172self.parent = parent 173self.child = child 174 175 176class PictureObject(db.Model): 177id = db.Column(db.String(64), primary_key=True) 178description = db.Column(db.UnicodeText, nullable=False) 179 180child_links = db.relationship("PictureObjectInheritance", 181foreign_keys=[PictureObjectInheritance.parent_id], 182back_populates="parent") 183parent_links = db.relationship("PictureObjectInheritance", 184foreign_keys=[PictureObjectInheritance.child_id], 185back_populates="child") 186 187def __init__(self, id, description): 188self.id = id 189self.description = description 190 191 192class PictureRegion(db.Model): 193# This is for picture region annotations 194id = db.Column(db.Integer, primary_key=True, autoincrement=True) 195json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 196 197resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 198nullable=False) 199object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 200 201resource = db.relationship("PictureResource", backref="regions") 202object = db.relationship("PictureObject", backref="regions") 203 204def __init__(self, json, resource, object): 205self.json = json 206self.resource = resource 207self.object = object 208 209 210class PictureResource(Resource): 211# This is only for bitmap pictures. Vectors will be stored under a different model 212# File name is the ID in the picture directory under data, without an extension 213file_format = db.Column(db.String(64), nullable=False) # MIME type 214width = db.Column(db.Integer, nullable=False) 215height = db.Column(db.Integer, nullable=False) 216nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 217author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 218author = db.relationship("User", back_populates="pictures") 219 220nature = db.relationship("PictureNature", back_populates="resources") 221 222replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 223replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 224nullable=True) 225 226replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 227foreign_keys=[replaces_id], back_populates="replaced_by", 228post_update=True) 229replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 230foreign_keys=[replaced_by_id], post_update=True) 231 232copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 233nullable=True) 234copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 235backref="copies", foreign_keys=[copied_from_id]) 236 237licences = db.relationship("PictureLicence", back_populates="resource") 238galleries = db.relationship("PictureInGallery", back_populates="resource") 239ratings = db.relationship("PictureRating", back_populates="resource") 240 241def __init__(self, title, author, description, origin_url, licence_ids, mime, 242nature=None): 243self.title = title 244self.author = author 245self.description = description 246self.origin_url = origin_url 247self.file_format = mime 248self.width = self.height = 0 249self.nature = nature 250db.session.add(self) 251db.session.commit() 252for licence_id in licence_ids: 253joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 254db.session.add(joiner) 255 256def put_annotations(self, json): 257# Delete all previous annotations 258db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 259 260for region in json: 261object_id = region["object"] 262picture_object = db.session.get(PictureObject, object_id) 263 264region_data = { 265"type": region["type"], 266"shape": region["shape"], 267} 268 269region_row = PictureRegion(region_data, self, picture_object) 270db.session.add(region_row) 271 272@property 273def average_rating(self): 274if not self.ratings: 275return None 276return db.session.query(db.func.avg(PictureRating.rating)).filter_by(resource=self).scalar() 277 278@property 279def rating_totals(self): 280all_ratings = db.session.query(PictureRating.rating).filter_by(resource=self) 281return {rating: all_ratings.filter_by(rating=rating).count() for rating in range(1, 6)} 282 283@property 284def stars(self): 285if not self.ratings: 286return 0 287average = self.average_rating 288whole_stars = int(average) 289partial_star = average - whole_stars 290 291return [100] * whole_stars + [int(partial_star * 100)] + [0] * (4 - whole_stars) 292 293 294class PictureInGallery(db.Model): 295id = db.Column(db.Integer, primary_key=True, autoincrement=True) 296resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 297nullable=False) 298gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 299 300resource = db.relationship("PictureResource") 301gallery = db.relationship("Gallery") 302 303def __init__(self, resource, gallery): 304self.resource = resource 305self.gallery = gallery 306 307 308class UserInGallery(db.Model): 309id = db.Column(db.Integer, primary_key=True, autoincrement=True) 310username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 311gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 312 313user = db.relationship("User") 314gallery = db.relationship("Gallery") 315 316def __init__(self, user, gallery): 317self.user = user 318self.gallery = gallery 319 320 321class Gallery(db.Model): 322id = db.Column(db.Integer, primary_key=True, autoincrement=True) 323title = db.Column(db.UnicodeText, nullable=False) 324description = db.Column(db.UnicodeText, nullable=False) 325pictures = db.relationship("PictureInGallery", back_populates="gallery") 326owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 327owner = db.relationship("User", back_populates="galleries") 328users = db.relationship("UserInGallery", back_populates="gallery") 329 330def __init__(self, title, description, owner): 331self.title = title 332self.description = description 333self.owner = owner 334 335 336class PictureRating(db.Model): 337id = db.Column(db.Integer, primary_key=True, autoincrement=True) 338resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 339username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 340rating = db.Column(db.Integer, db.CheckConstraint("rating >= 1 AND rating <= 5"), 341nullable=False) 342 343resource = db.relationship("PictureResource", back_populates="ratings") 344user = db.relationship("User", back_populates="ratings") 345 346def __init__(self, resource, user, rating): 347self.resource = resource 348self.user = user 349self.rating = rating 350 351 352@app.route("/") 353def index(): 354return flask.render_template("home.html", resources=PictureResource.query.order_by( 355db.func.random()).limit(10).all()) 356 357 358@app.route("/accounts/") 359def accounts(): 360return flask.render_template("login.html") 361 362 363@app.route("/login", methods=["POST"]) 364def login(): 365username = flask.request.form["username"] 366password = flask.request.form["password"] 367 368user = db.session.get(User, username) 369 370if user is None: 371flask.flash("This username is not registered.") 372return flask.redirect("/accounts") 373 374if not bcrypt.check_password_hash(user.password_hashed, password): 375flask.flash("Incorrect password.") 376return flask.redirect("/accounts") 377 378flask.flash("You have been logged in.") 379 380flask.session["username"] = username 381return flask.redirect("/") 382 383 384@app.route("/logout") 385def logout(): 386flask.session.pop("username", None) 387flask.flash("You have been logged out.") 388return flask.redirect("/") 389 390 391@app.route("/signup", methods=["POST"]) 392def signup(): 393username = flask.request.form["username"] 394password = flask.request.form["password"] 395 396if db.session.get(User, username) is not None: 397flask.flash("This username is already taken.") 398return flask.redirect("/accounts") 399 400if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 401flask.flash( 402"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 403return flask.redirect("/accounts") 404 405if len(username) < 3 or len(username) > 32: 406flask.flash("Usernames must be between 3 and 32 characters long.") 407return flask.redirect("/accounts") 408 409if len(password) < 6: 410flask.flash("Passwords must be at least 6 characters long.") 411return flask.redirect("/accounts") 412 413user = User(username, password) 414db.session.add(user) 415db.session.commit() 416 417flask.session["username"] = username 418 419flask.flash("You have been registered and logged in.") 420 421return flask.redirect("/") 422 423 424@app.route("/profile", defaults={"username": None}) 425@app.route("/profile/<username>") 426def profile(username): 427if username is None: 428if "username" in flask.session: 429return flask.redirect("/profile/" + flask.session["username"]) 430else: 431flask.flash("Please log in to perform this action.") 432return flask.redirect("/accounts") 433 434user = db.session.get(User, username) 435if user is None: 436flask.abort(404) 437 438return flask.render_template("profile.html", user=user) 439 440 441@app.route("/object/<id>") 442def has_object(id): 443object_ = db.session.get(PictureObject, id) 444if object_ is None: 445flask.abort(404) 446 447query = db.session.query(PictureResource).join(PictureRegion).filter( 448PictureRegion.object_id == id) 449 450page = int(flask.request.args.get("page", 1)) 451per_page = int(flask.request.args.get("per_page", 16)) 452 453resources = query.paginate(page=page, per_page=per_page) 454 455return flask.render_template("object.html", object=object_, resources=resources, 456page_number=page, 457page_length=per_page, num_pages=resources.pages, 458prev_page=resources.prev_num, 459next_page=resources.next_num, PictureRegion=PictureRegion) 460 461 462@app.route("/upload") 463def upload(): 464if "username" not in flask.session: 465flask.flash("Log in to upload pictures.") 466return flask.redirect("/accounts") 467 468licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 469Licence.title).all() 470 471types = PictureNature.query.all() 472 473return flask.render_template("upload.html", licences=licences, types=types) 474 475 476@app.route("/upload", methods=["POST"]) 477def upload_post(): 478title = flask.request.form["title"] 479description = flask.request.form["description"] 480origin_url = flask.request.form["origin_url"] 481author = db.session.get(User, flask.session.get("username")) 482licence_ids = flask.request.form.getlist("licence") 483nature_id = flask.request.form["nature"] 484 485if author is None: 486flask.abort(401) 487 488file = flask.request.files["file"] 489 490if not file or not file.filename: 491flask.flash("Select a file") 492return flask.redirect(flask.request.url) 493 494if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 495flask.flash("Only images are supported") 496return flask.redirect(flask.request.url) 497 498if not title: 499flask.flash("Enter a title") 500return flask.redirect(flask.request.url) 501 502if not description: 503description = "" 504 505if not nature_id: 506flask.flash("Select a picture type") 507return flask.redirect(flask.request.url) 508 509if not licence_ids: 510flask.flash("Select licences") 511return flask.redirect(flask.request.url) 512 513licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 514if not any(licence.free for licence in licences): 515flask.flash("Select at least one free licence") 516return flask.redirect(flask.request.url) 517 518resource = PictureResource(title, author, description, origin_url, licence_ids, 519file.mimetype, 520db.session.get(PictureNature, nature_id)) 521db.session.add(resource) 522db.session.commit() 523file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 524pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 525resource.width, resource.height = pil_image.size 526db.session.commit() 527 528if flask.request.form.get("annotations"): 529try: 530resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 531db.session.commit() 532except json.JSONDecodeError: 533flask.flash("Invalid annotations") 534 535flask.flash("Picture uploaded successfully") 536 537return flask.redirect("/picture/" + str(resource.id)) 538 539 540@app.route("/picture/<int:id>/") 541def picture(id): 542resource = db.session.get(PictureResource, id) 543if resource is None: 544flask.abort(404) 545 546image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 547 548current_user = db.session.get(User, flask.session.get("username")) 549have_permission = current_user and (current_user == resource.author or current_user.admin) 550 551own_rating = None 552if current_user: 553own_rating = PictureRating.query.filter_by(resource=resource, user=current_user).first() 554 555return flask.render_template("picture.html", resource=resource, 556file_extension=mimetypes.guess_extension(resource.file_format), 557size=image.size, copies=resource.copies, 558have_permission=have_permission, own_rating=own_rating) 559 560 561@app.route("/picture/<int:id>/annotate") 562def annotate_picture(id): 563resource = db.session.get(PictureResource, id) 564if resource is None: 565flask.abort(404) 566 567current_user = db.session.get(User, flask.session.get("username")) 568if current_user is None: 569flask.abort(401) 570if resource.author != current_user and not current_user.admin: 571flask.abort(403) 572 573return flask.render_template("picture-annotation.html", resource=resource, 574file_extension=mimetypes.guess_extension(resource.file_format)) 575 576 577@app.route("/picture/<int:id>/put-annotations-form") 578def put_annotations_form(id): 579resource = db.session.get(PictureResource, id) 580if resource is None: 581flask.abort(404) 582 583current_user = db.session.get(User, flask.session.get("username")) 584if current_user is None: 585flask.abort(401) 586 587if resource.author != current_user and not current_user.admin: 588flask.abort(403) 589 590return flask.render_template("put-annotations-form.html", resource=resource) 591 592 593@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 594def put_annotations_form_post(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 606resource.put_annotations(json.loads(flask.request.form["annotations"])) 607 608db.session.commit() 609 610return flask.redirect("/picture/" + str(resource.id)) 611 612 613@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 614@app.route("/api/picture/<int:id>/put-annotations", methods=["POST"]) 615def save_annotations(id): 616resource = db.session.get(PictureResource, id) 617if resource is None: 618flask.abort(404) 619 620current_user = db.session.get(User, flask.session.get("username")) 621if resource.author != current_user and not current_user.admin: 622flask.abort(403) 623 624resource.put_annotations(flask.request.json) 625 626db.session.commit() 627 628response = flask.make_response() 629response.status_code = 204 630return response 631 632 633@app.route("/picture/<int:id>/get-annotations") 634@app.route("/api/picture/<int:id>/api/get-annotations") 635def get_annotations(id): 636resource = db.session.get(PictureResource, id) 637if resource is None: 638flask.abort(404) 639 640regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 641 642regions_json = [] 643 644for region in regions: 645regions_json.append({ 646"object": region.object_id, 647"type": region.json["type"], 648"shape": region.json["shape"], 649}) 650 651return flask.jsonify(regions_json) 652 653 654@app.route("/picture/<int:id>/delete") 655def delete_picture(id): 656resource = db.session.get(PictureResource, id) 657if resource is None: 658flask.abort(404) 659 660current_user = db.session.get(User, flask.session.get("username")) 661if current_user is None: 662flask.abort(401) 663 664if resource.author != current_user and not current_user.admin: 665flask.abort(403) 666 667PictureLicence.query.filter_by(resource=resource).delete() 668PictureRegion.query.filter_by(resource=resource).delete() 669PictureInGallery.query.filter_by(resource=resource).delete() 670if resource.replaces: 671resource.replaces.replaced_by = None 672if resource.replaced_by: 673resource.replaced_by.replaces = None 674resource.copied_from = None 675for copy in resource.copies: 676copy.copied_from = None 677db.session.delete(resource) 678db.session.commit() 679 680return flask.redirect("/") 681 682 683@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 684def mark_picture_replacement(id): 685resource = db.session.get(PictureResource, id) 686if resource is None: 687flask.abort(404) 688 689current_user = db.session.get(User, flask.session.get("username")) 690if current_user is None: 691flask.abort(401) 692 693if resource.copied_from.author != current_user and not current_user.admin: 694flask.abort(403) 695 696resource.copied_from.replaced_by = resource 697resource.replaces = resource.copied_from 698 699db.session.commit() 700 701return flask.redirect("/picture/" + str(resource.copied_from.id)) 702 703 704@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 705def remove_picture_replacement(id): 706resource = db.session.get(PictureResource, id) 707if resource is None: 708flask.abort(404) 709 710current_user = db.session.get(User, flask.session.get("username")) 711if current_user is None: 712flask.abort(401) 713 714if resource.author != current_user and not current_user.admin: 715flask.abort(403) 716 717resource.replaced_by.replaces = None 718resource.replaced_by = None 719 720db.session.commit() 721 722return flask.redirect("/picture/" + str(resource.id)) 723 724 725@app.route("/picture/<int:id>/edit-metadata") 726def edit_picture(id): 727resource = db.session.get(PictureResource, id) 728if resource is None: 729flask.abort(404) 730 731current_user = db.session.get(User, flask.session.get("username")) 732if current_user is None: 733flask.abort(401) 734 735if resource.author != current_user and not current_user.admin: 736flask.abort(403) 737 738licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 739Licence.title).all() 740 741types = PictureNature.query.all() 742 743return flask.render_template("edit-picture.html", resource=resource, licences=licences, 744types=types, 745PictureLicence=PictureLicence) 746 747 748@app.route("/picture/<int:id>/rate", methods=["POST"]) 749def rate_picture(id): 750resource = db.session.get(PictureResource, id) 751if resource is None: 752flask.abort(404) 753 754current_user = db.session.get(User, flask.session.get("username")) 755if current_user is None: 756flask.abort(401) 757 758rating = int(flask.request.form.get("rating")) 759 760if not rating: 761# Delete the existing rating 762if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 763db.session.delete(PictureRating.query.filter_by(resource=resource, 764user=current_user).first()) 765db.session.commit() 766 767return flask.redirect("/picture/" + str(resource.id)) 768 769if not 1 <= rating <= 5: 770flask.flash("Invalid rating") 771return flask.redirect("/picture/" + str(resource.id)) 772 773if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 774PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 775else: 776# Create a new rating 777db.session.add(PictureRating(resource, current_user, rating)) 778 779db.session.commit() 780 781return flask.redirect("/picture/" + str(resource.id)) 782 783 784@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 785def edit_picture_post(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 794if resource.author != current_user and not current_user.admin: 795flask.abort(403) 796 797title = flask.request.form["title"] 798description = flask.request.form["description"] 799origin_url = flask.request.form["origin_url"] 800licence_ids = flask.request.form.getlist("licence") 801nature_id = flask.request.form["nature"] 802 803if not title: 804flask.flash("Enter a title") 805return flask.redirect(flask.request.url) 806 807if not description: 808description = "" 809 810if not nature_id: 811flask.flash("Select a picture type") 812return flask.redirect(flask.request.url) 813 814if not licence_ids: 815flask.flash("Select licences") 816return flask.redirect(flask.request.url) 817 818licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 819if not any(licence.free for licence in licences): 820flask.flash("Select at least one free licence") 821return flask.redirect(flask.request.url) 822 823resource.title = title 824resource.description = description 825resource.origin_url = origin_url 826for licence_id in licence_ids: 827joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 828db.session.add(joiner) 829resource.nature = db.session.get(PictureNature, nature_id) 830 831db.session.commit() 832 833return flask.redirect("/picture/" + str(resource.id)) 834 835 836@app.route("/picture/<int:id>/copy") 837def copy_picture(id): 838resource = db.session.get(PictureResource, id) 839if resource is None: 840flask.abort(404) 841 842current_user = db.session.get(User, flask.session.get("username")) 843if current_user is None: 844flask.abort(401) 845 846new_resource = PictureResource(resource.title, current_user, resource.description, 847resource.origin_url, 848[licence.licence_id for licence in resource.licences], 849resource.file_format, 850resource.nature) 851 852for region in resource.regions: 853db.session.add(PictureRegion(region.json, new_resource, region.object)) 854 855db.session.commit() 856 857# Create a hard link for the new picture 858old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 859new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 860os.link(old_path, new_path) 861 862new_resource.width = resource.width 863new_resource.height = resource.height 864new_resource.copied_from = resource 865 866db.session.commit() 867 868return flask.redirect("/picture/" + str(new_resource.id)) 869 870 871@app.route("/gallery/<int:id>/") 872def gallery(id): 873gallery = db.session.get(Gallery, id) 874if gallery is None: 875flask.abort(404) 876 877current_user = db.session.get(User, flask.session.get("username")) 878 879have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 880 881return flask.render_template("gallery.html", gallery=gallery, 882have_permission=have_permission) 883 884 885@app.route("/create-gallery") 886def create_gallery(): 887if "username" not in flask.session: 888flask.flash("Log in to create galleries.") 889return flask.redirect("/accounts") 890 891return flask.render_template("create-gallery.html") 892 893 894@app.route("/create-gallery", methods=["POST"]) 895def create_gallery_post(): 896if not flask.session.get("username"): 897flask.abort(401) 898 899if not flask.request.form.get("title"): 900flask.flash("Enter a title") 901return flask.redirect(flask.request.url) 902 903description = flask.request.form.get("description", "") 904 905gallery = Gallery(flask.request.form["title"], description, 906db.session.get(User, flask.session["username"])) 907db.session.add(gallery) 908db.session.commit() 909 910return flask.redirect("/gallery/" + str(gallery.id)) 911 912 913@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 914def gallery_add_picture(id): 915gallery = db.session.get(Gallery, id) 916if gallery is None: 917flask.abort(404) 918 919if "username" not in flask.session: 920flask.abort(401) 921 922if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 923flask.abort(403) 924 925picture_id = flask.request.form.get("picture_id") 926if "/" in picture_id: # also allow full URLs 927picture_id = picture_id.rstrip("/").rpartition("/")[1] 928if not picture_id: 929flask.flash("Select a picture") 930return flask.redirect("/gallery/" + str(gallery.id)) 931picture_id = int(picture_id) 932 933picture = db.session.get(PictureResource, picture_id) 934if picture is None: 935flask.flash("Invalid picture") 936return flask.redirect("/gallery/" + str(gallery.id)) 937 938if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 939flask.flash("This picture is already in the gallery") 940return flask.redirect("/gallery/" + str(gallery.id)) 941 942db.session.add(PictureInGallery(picture, gallery)) 943 944db.session.commit() 945 946return flask.redirect("/gallery/" + str(gallery.id)) 947 948 949@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 950def gallery_remove_picture(id): 951gallery = db.session.get(Gallery, id) 952if gallery is None: 953flask.abort(404) 954 955if "username" not in flask.session: 956flask.abort(401) 957 958current_user = db.session.get(User, flask.session.get("username")) 959 960if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 961flask.abort(403) 962 963picture_id = int(flask.request.form.get("picture_id")) 964 965picture = db.session.get(PictureResource, picture_id) 966if picture is None: 967flask.flash("Invalid picture") 968return flask.redirect("/gallery/" + str(gallery.id)) 969 970picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 971gallery=gallery).first() 972if picture_in_gallery is None: 973flask.flash("This picture isn't in the gallery") 974return flask.redirect("/gallery/" + str(gallery.id)) 975 976db.session.delete(picture_in_gallery) 977 978db.session.commit() 979 980return flask.redirect("/gallery/" + str(gallery.id)) 981 982 983@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 984def gallery_add_from_query(id): 985gallery = db.session.get(Gallery, id) 986if gallery is None: 987flask.abort(404) 988 989if "username" not in flask.session: 990flask.abort(401) 991 992if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 993flask.abort(403) 994 995query_yaml = flask.request.form.get("query", "") 996 997yaml_parser = yaml.YAML() 998query_data = yaml_parser.load(query_yaml) or {} 999query = get_picture_query(query_data) 1000 1001pictures = query.all() 1002 1003count = 0 1004 1005for picture in pictures: 1006if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1007db.session.add(PictureInGallery(picture, gallery)) 1008count += 1 1009 1010db.session.commit() 1011 1012flask.flash(f"Added {count} pictures to the gallery") 1013 1014return flask.redirect("/gallery/" + str(gallery.id)) 1015 1016 1017@app.route("/gallery/<int:id>/users") 1018def gallery_users(id): 1019gallery = db.session.get(Gallery, id) 1020if gallery is None: 1021flask.abort(404) 1022 1023current_user = db.session.get(User, flask.session.get("username")) 1024have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1025 1026return flask.render_template("gallery-users.html", gallery=gallery, 1027have_permission=have_permission) 1028 1029 1030@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1031def gallery_add_user(id): 1032gallery = db.session.get(Gallery, id) 1033if gallery is None: 1034flask.abort(404) 1035 1036current_user = db.session.get(User, flask.session.get("username")) 1037if current_user is None: 1038flask.abort(401) 1039 1040if current_user != gallery.owner and not current_user.admin: 1041flask.abort(403) 1042 1043username = flask.request.form.get("username") 1044if username == gallery.owner_name: 1045flask.flash("The owner is already in the gallery") 1046return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1047 1048user = db.session.get(User, username) 1049if user is None: 1050flask.flash("User not found") 1051return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1052 1053if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1054flask.flash("User is already in the gallery") 1055return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1056 1057db.session.add(UserInGallery(user, gallery)) 1058 1059db.session.commit() 1060 1061return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1062 1063 1064@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1065def gallery_remove_user(id): 1066gallery = db.session.get(Gallery, id) 1067if gallery is None: 1068flask.abort(404) 1069 1070current_user = db.session.get(User, flask.session.get("username")) 1071if current_user is None: 1072flask.abort(401) 1073 1074if current_user != gallery.owner and not current_user.admin: 1075flask.abort(403) 1076 1077username = flask.request.form.get("username") 1078user = db.session.get(User, username) 1079if user is None: 1080flask.flash("User not found") 1081return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1082 1083user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1084if user_in_gallery is None: 1085flask.flash("User is not in the gallery") 1086return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1087 1088db.session.delete(user_in_gallery) 1089 1090db.session.commit() 1091 1092return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1093 1094 1095class APIError(Exception): 1096def __init__(self, status_code, message): 1097self.status_code = status_code 1098self.message = message 1099 1100 1101def get_picture_query(query_data): 1102query = db.session.query(PictureResource) 1103 1104requirement_conditions = { 1105"has_object": lambda value: PictureResource.regions.any( 1106PictureRegion.object_id.in_(value)), 1107"nature": lambda value: PictureResource.nature_id.in_(value), 1108"licence": lambda value: PictureResource.licences.any( 1109PictureLicence.licence_id.in_(value)), 1110"author": lambda value: PictureResource.author_name.in_(value), 1111"title": lambda value: PictureResource.title.ilike(value), 1112"description": lambda value: PictureResource.description.ilike(value), 1113"origin_url": lambda value: db.func.lower(db.func.substr( 1114PictureResource.origin_url, 1115db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1116)).in_(value), 1117"above_width": lambda value: PictureResource.width >= value, 1118"below_width": lambda value: PictureResource.width <= value, 1119"above_height": lambda value: PictureResource.height >= value, 1120"below_height": lambda value: PictureResource.height <= value, 1121"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1122value), 1123"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1124value), 1125"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1126} 1127if "want" in query_data: 1128for i in query_data["want"]: 1129if len(i) != 1: 1130raise APIError(400, "Each requirement must have exactly one key") 1131requirement, value = list(i.items())[0] 1132if requirement not in requirement_conditions: 1133raise APIError(400, f"Unknown requirement type: {requirement}") 1134 1135condition = requirement_conditions[requirement] 1136query = query.filter(condition(value)) 1137if "exclude" in query_data: 1138for i in query_data["exclude"]: 1139if len(i) != 1: 1140raise APIError(400, "Each exclusion must have exactly one key") 1141requirement, value = list(i.items())[0] 1142if requirement not in requirement_conditions: 1143raise APIError(400, f"Unknown requirement type: {requirement}") 1144 1145condition = requirement_conditions[requirement] 1146query = query.filter(~condition(value)) 1147if not query_data.get("include_obsolete", False): 1148query = query.filter(PictureResource.replaced_by_id.is_(None)) 1149 1150return query 1151 1152 1153@app.route("/query-pictures") 1154def graphical_query_pictures(): 1155return flask.render_template("graphical-query-pictures.html") 1156 1157 1158@app.route("/query-pictures-results") 1159def graphical_query_pictures_results(): 1160query_yaml = flask.request.args.get("query", "") 1161yaml_parser = yaml.YAML() 1162query_data = yaml_parser.load(query_yaml) or {} 1163try: 1164query = get_picture_query(query_data) 1165except APIError as e: 1166flask.abort(e.status_code) 1167 1168page = int(flask.request.args.get("page", 1)) 1169per_page = int(flask.request.args.get("per_page", 16)) 1170 1171resources = query.paginate(page=page, per_page=per_page) 1172 1173return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1174query=query_yaml, 1175page_number=page, page_length=per_page, 1176num_pages=resources.pages, 1177prev_page=resources.prev_num, next_page=resources.next_num) 1178 1179 1180@app.route("/raw/picture/<int:id>") 1181def raw_picture(id): 1182resource = db.session.get(PictureResource, id) 1183if resource is None: 1184flask.abort(404) 1185 1186response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1187str(resource.id)) 1188response.mimetype = resource.file_format 1189 1190return response 1191 1192 1193@app.route("/object/") 1194def graphical_object_types(): 1195return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1196 1197 1198@app.route("/api/object-types") 1199def object_types(): 1200objects = db.session.query(PictureObject).all() 1201return flask.jsonify({object.id: object.description for object in objects}) 1202 1203 1204@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1205def query_pictures(): 1206offset = int(flask.request.args.get("offset", 0)) 1207limit = int(flask.request.args.get("limit", 16)) 1208ordering = flask.request.args.get("ordering", "date-desc") 1209 1210yaml_parser = yaml.YAML() 1211query_data = yaml_parser.load(flask.request.data) or {} 1212try: 1213query = get_picture_query(query_data) 1214except APIError as e: 1215return flask.jsonify({"error": e.message}), e.status_code 1216 1217match ordering: 1218case "date-desc": 1219query = query.order_by(PictureResource.timestamp.desc()) 1220case "date-asc": 1221query = query.order_by(PictureResource.timestamp.asc()) 1222case "title-asc": 1223query = query.order_by(PictureResource.title.asc()) 1224case "title-desc": 1225query = query.order_by(PictureResource.title.desc()) 1226case "random": 1227query = query.order_by(db.func.random()) 1228case "number-regions-desc": 1229query = query.order_by(db.func.count(PictureResource.regions).desc()) 1230case "number-regions-asc": 1231query = query.order_by(db.func.count(PictureResource.regions).asc()) 1232 1233query = query.offset(offset).limit(limit) 1234resources = query.all() 1235 1236json_response = { 1237"date_generated": datetime.utcnow().timestamp(), 1238"resources": [], 1239"offset": offset, 1240"limit": limit, 1241} 1242 1243json_resources = json_response["resources"] 1244 1245for resource in resources: 1246json_resource = { 1247"id": resource.id, 1248"title": resource.title, 1249"description": resource.description, 1250"timestamp": resource.timestamp.timestamp(), 1251"origin_url": resource.origin_url, 1252"author": resource.author_name, 1253"file_format": resource.file_format, 1254"width": resource.width, 1255"height": resource.height, 1256"nature": resource.nature_id, 1257"licences": [licence.licence_id for licence in resource.licences], 1258"replaces": resource.replaces_id, 1259"replaced_by": resource.replaced_by_id, 1260"regions": [], 1261"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1262} 1263for region in resource.regions: 1264json_resource["regions"].append({ 1265"object": region.object_id, 1266"type": region.json["type"], 1267"shape": region.json["shape"], 1268}) 1269 1270json_resources.append(json_resource) 1271 1272return flask.jsonify(json_response) 1273 1274 1275@app.route("/api/picture/<int:id>/") 1276def api_picture(id): 1277resource = db.session.get(PictureResource, id) 1278if resource is None: 1279flask.abort(404) 1280 1281json_resource = { 1282"id": resource.id, 1283"title": resource.title, 1284"description": resource.description, 1285"timestamp": resource.timestamp.timestamp(), 1286"origin_url": resource.origin_url, 1287"author": resource.author_name, 1288"file_format": resource.file_format, 1289"width": resource.width, 1290"height": resource.height, 1291"nature": resource.nature_id, 1292"licences": [licence.licence_id for licence in resource.licences], 1293"replaces": resource.replaces_id, 1294"replaced_by": resource.replaced_by_id, 1295"regions": [], 1296"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1297} 1298for region in resource.regions: 1299json_resource["regions"].append({ 1300"object": region.object_id, 1301"type": region.json["type"], 1302"shape": region.json["shape"], 1303}) 1304 1305return flask.jsonify(json_resource) 1306 1307 1308@app.route("/api/licence/") 1309def api_licences(): 1310licences = db.session.query(Licence).all() 1311json_licences = { 1312licence.id: { 1313"title": licence.title, 1314"free": licence.free, 1315"pinned": licence.pinned, 1316} for licence in licences 1317} 1318 1319return flask.jsonify(json_licences) 1320 1321 1322@app.route("/api/licence/<id>/") 1323def api_licence(id): 1324licence = db.session.get(Licence, id) 1325if licence is None: 1326flask.abort(404) 1327 1328json_licence = { 1329"id": licence.id, 1330"title": licence.title, 1331"description": licence.description, 1332"info_url": licence.info_url, 1333"legalese_url": licence.url, 1334"free": licence.free, 1335"logo_url": licence.logo_url, 1336"pinned": licence.pinned, 1337} 1338 1339return flask.jsonify(json_licence) 1340 1341 1342@app.route("/api/nature/") 1343def api_natures(): 1344natures = db.session.query(PictureNature).all() 1345json_natures = { 1346nature.id: nature.description for nature in natures 1347} 1348 1349return flask.jsonify(json_natures) 1350 1351 1352@app.route("/api/user/") 1353def api_users(): 1354offset = int(flask.request.args.get("offset", 0)) 1355limit = int(flask.request.args.get("limit", 16)) 1356 1357users = db.session.query(User).offset(offset).limit(limit).all() 1358 1359json_users = { 1360user.username: { 1361"admin": user.admin, 1362} for user in users 1363} 1364 1365return flask.jsonify(json_users) 1366 1367 1368@app.route("/api/user/<username>/") 1369def api_user(username): 1370user = db.session.get(User, username) 1371if user is None: 1372flask.abort(404) 1373 1374json_user = { 1375"username": user.username, 1376"admin": user.admin, 1377"joined": user.joined_timestamp.timestamp(), 1378} 1379 1380return flask.jsonify(json_user) 1381 1382 1383@app.route("/api/login", methods=["POST"]) 1384def api_login(): 1385username = flask.request.json["username"] 1386password = flask.request.json["password"] 1387 1388user = db.session.get(User, username) 1389 1390if user is None: 1391return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1392 1393if not bcrypt.check_password_hash(user.password_hashed, password): 1394return flask.jsonify({"error": "Incorrect password"}), 401 1395 1396flask.session["username"] = username 1397 1398return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1399 1400 1401@app.route("/api/logout", methods=["POST"]) 1402def api_logout(): 1403flask.session.pop("username", None) 1404return flask.jsonify({"message": "You have been logged out."}) 1405 1406 1407@app.route("/api/upload", methods=["POST"]) 1408def api_upload(): 1409if "username" not in flask.session: 1410return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1411 1412json_ = json.loads(flask.request.form["json"]) 1413title = json_["title"] 1414description = json_.get("description", "") 1415origin_url = json_.get("origin_url", "") 1416author = db.session.get(User, flask.session["username"]) 1417licence_ids = json_["licence"] 1418nature_id = json_["nature"] 1419file = flask.request.files["file"] 1420 1421if not file or not file.filename: 1422return flask.jsonify({"error": "An image file must be uploaded"}), 400 1423 1424if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1425return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1426 1427if not title: 1428return flask.jsonify({"error": "Give a title"}), 400 1429 1430if not description: 1431description = "" 1432 1433if not nature_id: 1434return flask.jsonify({"error": "Give a picture type"}), 400 1435 1436if not licence_ids: 1437return flask.jsonify({"error": "Give licences"}), 400 1438 1439licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1440if not any(licence.free for licence in licences): 1441return flask.jsonify({"error": "Use at least one free licence"}), 400 1442 1443resource = PictureResource(title, author, description, origin_url, licence_ids, 1444file.mimetype, 1445db.session.get(PictureNature, nature_id)) 1446db.session.add(resource) 1447db.session.commit() 1448file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1449pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1450resource.width, resource.height = pil_image.size 1451db.session.commit() 1452 1453if json_.get("annotations"): 1454try: 1455resource.put_annotations(json_["annotations"]) 1456db.session.commit() 1457except json.JSONDecodeError: 1458return flask.jsonify({"error": "Invalid annotations"}), 400 1459 1460return flask.jsonify({"message": "Picture uploaded successfully"}) 1461 1462 1463@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1464def api_update_picture(id): 1465resource = db.session.get(PictureResource, id) 1466if resource is None: 1467return flask.jsonify({"error": "Picture not found"}), 404 1468current_user = db.session.get(User, flask.session.get("username")) 1469if current_user is None: 1470return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1471if resource.author != current_user and not current_user.admin: 1472return flask.jsonify({"error": "You are not the author of this picture"}), 403 1473 1474title = flask.request.json.get("title", resource.title) 1475description = flask.request.json.get("description", resource.description) 1476origin_url = flask.request.json.get("origin_url", resource.origin_url) 1477licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1478nature_id = flask.request.json.get("nature", resource.nature_id) 1479 1480if not title: 1481return flask.jsonify({"error": "Give a title"}), 400 1482 1483if not description: 1484description = "" 1485 1486if not nature_id: 1487return flask.jsonify({"error": "Give a picture type"}), 400 1488 1489if not licence_ids: 1490return flask.jsonify({"error": "Give licences"}), 400 1491 1492licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1493 1494if not any(licence.free for licence in licences): 1495return flask.jsonify({"error": "Use at least one free licence"}), 400 1496 1497resource.title = title 1498resource.description = description 1499resource.origin_url = origin_url 1500resource.licences = licences 1501resource.nature = db.session.get(PictureNature, nature_id) 1502 1503db.session.commit() 1504 1505return flask.jsonify({"message": "Picture updated successfully"}) 1506 1507