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 284class PictureInGallery(db.Model): 285id = db.Column(db.Integer, primary_key=True, autoincrement=True) 286resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 287nullable=False) 288gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 289 290resource = db.relationship("PictureResource") 291gallery = db.relationship("Gallery") 292 293def __init__(self, resource, gallery): 294self.resource = resource 295self.gallery = gallery 296 297 298class UserInGallery(db.Model): 299id = db.Column(db.Integer, primary_key=True, autoincrement=True) 300username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 301gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 302 303user = db.relationship("User") 304gallery = db.relationship("Gallery") 305 306def __init__(self, user, gallery): 307self.user = user 308self.gallery = gallery 309 310 311class Gallery(db.Model): 312id = db.Column(db.Integer, primary_key=True, autoincrement=True) 313title = db.Column(db.UnicodeText, nullable=False) 314description = db.Column(db.UnicodeText, nullable=False) 315pictures = db.relationship("PictureInGallery", back_populates="gallery") 316owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 317owner = db.relationship("User", back_populates="galleries") 318users = db.relationship("UserInGallery", back_populates="gallery") 319 320def __init__(self, title, description, owner): 321self.title = title 322self.description = description 323self.owner = owner 324 325 326class PictureRating(db.Model): 327id = db.Column(db.Integer, primary_key=True, autoincrement=True) 328resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 329username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 330rating = db.Column(db.Integer, db.CheckConstraint("rating >= 1 AND rating <= 5"), 331nullable=False) 332 333resource = db.relationship("PictureResource", back_populates="ratings") 334user = db.relationship("User", back_populates="ratings") 335 336def __init__(self, resource, user, rating): 337self.resource = resource 338self.user = user 339self.rating = rating 340 341 342@app.route("/") 343def index(): 344return flask.render_template("home.html", resources=PictureResource.query.order_by( 345db.func.random()).limit(10).all()) 346 347 348@app.route("/accounts/") 349def accounts(): 350return flask.render_template("login.html") 351 352 353@app.route("/login", methods=["POST"]) 354def login(): 355username = flask.request.form["username"] 356password = flask.request.form["password"] 357 358user = db.session.get(User, username) 359 360if user is None: 361flask.flash("This username is not registered.") 362return flask.redirect("/accounts") 363 364if not bcrypt.check_password_hash(user.password_hashed, password): 365flask.flash("Incorrect password.") 366return flask.redirect("/accounts") 367 368flask.flash("You have been logged in.") 369 370flask.session["username"] = username 371return flask.redirect("/") 372 373 374@app.route("/logout") 375def logout(): 376flask.session.pop("username", None) 377flask.flash("You have been logged out.") 378return flask.redirect("/") 379 380 381@app.route("/signup", methods=["POST"]) 382def signup(): 383username = flask.request.form["username"] 384password = flask.request.form["password"] 385 386if db.session.get(User, username) is not None: 387flask.flash("This username is already taken.") 388return flask.redirect("/accounts") 389 390if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 391flask.flash( 392"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 393return flask.redirect("/accounts") 394 395if len(username) < 3 or len(username) > 32: 396flask.flash("Usernames must be between 3 and 32 characters long.") 397return flask.redirect("/accounts") 398 399if len(password) < 6: 400flask.flash("Passwords must be at least 6 characters long.") 401return flask.redirect("/accounts") 402 403user = User(username, password) 404db.session.add(user) 405db.session.commit() 406 407flask.session["username"] = username 408 409flask.flash("You have been registered and logged in.") 410 411return flask.redirect("/") 412 413 414@app.route("/profile", defaults={"username": None}) 415@app.route("/profile/<username>") 416def profile(username): 417if username is None: 418if "username" in flask.session: 419return flask.redirect("/profile/" + flask.session["username"]) 420else: 421flask.flash("Please log in to perform this action.") 422return flask.redirect("/accounts") 423 424user = db.session.get(User, username) 425if user is None: 426flask.abort(404) 427 428return flask.render_template("profile.html", user=user) 429 430 431@app.route("/object/<id>") 432def has_object(id): 433object_ = db.session.get(PictureObject, id) 434if object_ is None: 435flask.abort(404) 436 437query = db.session.query(PictureResource).join(PictureRegion).filter( 438PictureRegion.object_id == id) 439 440page = int(flask.request.args.get("page", 1)) 441per_page = int(flask.request.args.get("per_page", 16)) 442 443resources = query.paginate(page=page, per_page=per_page) 444 445return flask.render_template("object.html", object=object_, resources=resources, 446page_number=page, 447page_length=per_page, num_pages=resources.pages, 448prev_page=resources.prev_num, 449next_page=resources.next_num, PictureRegion=PictureRegion) 450 451 452@app.route("/upload") 453def upload(): 454if "username" not in flask.session: 455flask.flash("Log in to upload pictures.") 456return flask.redirect("/accounts") 457 458licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 459Licence.title).all() 460 461types = PictureNature.query.all() 462 463return flask.render_template("upload.html", licences=licences, types=types) 464 465 466@app.route("/upload", methods=["POST"]) 467def upload_post(): 468title = flask.request.form["title"] 469description = flask.request.form["description"] 470origin_url = flask.request.form["origin_url"] 471author = db.session.get(User, flask.session.get("username")) 472licence_ids = flask.request.form.getlist("licence") 473nature_id = flask.request.form["nature"] 474 475if author is None: 476flask.abort(401) 477 478file = flask.request.files["file"] 479 480if not file or not file.filename: 481flask.flash("Select a file") 482return flask.redirect(flask.request.url) 483 484if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 485flask.flash("Only images are supported") 486return flask.redirect(flask.request.url) 487 488if not title: 489flask.flash("Enter a title") 490return flask.redirect(flask.request.url) 491 492if not description: 493description = "" 494 495if not nature_id: 496flask.flash("Select a picture type") 497return flask.redirect(flask.request.url) 498 499if not licence_ids: 500flask.flash("Select licences") 501return flask.redirect(flask.request.url) 502 503licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 504if not any(licence.free for licence in licences): 505flask.flash("Select at least one free licence") 506return flask.redirect(flask.request.url) 507 508resource = PictureResource(title, author, description, origin_url, licence_ids, 509file.mimetype, 510db.session.get(PictureNature, nature_id)) 511db.session.add(resource) 512db.session.commit() 513file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 514pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 515resource.width, resource.height = pil_image.size 516db.session.commit() 517 518if flask.request.form.get("annotations"): 519try: 520resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 521db.session.commit() 522except json.JSONDecodeError: 523flask.flash("Invalid annotations") 524 525flask.flash("Picture uploaded successfully") 526 527return flask.redirect("/picture/" + str(resource.id)) 528 529 530@app.route("/picture/<int:id>/") 531def picture(id): 532resource = db.session.get(PictureResource, id) 533if resource is None: 534flask.abort(404) 535 536image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 537 538current_user = db.session.get(User, flask.session.get("username")) 539have_permission = current_user and (current_user == resource.author or current_user.admin) 540 541own_rating = None 542if current_user: 543own_rating = PictureRating.query.filter_by(resource=resource, user=current_user).first() 544 545return flask.render_template("picture.html", resource=resource, 546file_extension=mimetypes.guess_extension(resource.file_format), 547size=image.size, copies=resource.copies, 548have_permission=have_permission, own_rating=own_rating) 549 550 551@app.route("/picture/<int:id>/annotate") 552def annotate_picture(id): 553resource = db.session.get(PictureResource, id) 554if resource is None: 555flask.abort(404) 556 557current_user = db.session.get(User, flask.session.get("username")) 558if current_user is None: 559flask.abort(401) 560if resource.author != current_user and not current_user.admin: 561flask.abort(403) 562 563return flask.render_template("picture-annotation.html", resource=resource, 564file_extension=mimetypes.guess_extension(resource.file_format)) 565 566 567@app.route("/picture/<int:id>/put-annotations-form") 568def put_annotations_form(id): 569resource = db.session.get(PictureResource, id) 570if resource is None: 571flask.abort(404) 572 573current_user = db.session.get(User, flask.session.get("username")) 574if current_user is None: 575flask.abort(401) 576 577if resource.author != current_user and not current_user.admin: 578flask.abort(403) 579 580return flask.render_template("put-annotations-form.html", resource=resource) 581 582 583@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 584def put_annotations_form_post(id): 585resource = db.session.get(PictureResource, id) 586if resource is None: 587flask.abort(404) 588 589current_user = db.session.get(User, flask.session.get("username")) 590if current_user is None: 591flask.abort(401) 592 593if resource.author != current_user and not current_user.admin: 594flask.abort(403) 595 596resource.put_annotations(json.loads(flask.request.form["annotations"])) 597 598db.session.commit() 599 600return flask.redirect("/picture/" + str(resource.id)) 601 602 603@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 604@app.route("/api/picture/<int:id>/put-annotations", methods=["POST"]) 605def save_annotations(id): 606resource = db.session.get(PictureResource, id) 607if resource is None: 608flask.abort(404) 609 610current_user = db.session.get(User, flask.session.get("username")) 611if resource.author != current_user and not current_user.admin: 612flask.abort(403) 613 614resource.put_annotations(flask.request.json) 615 616db.session.commit() 617 618response = flask.make_response() 619response.status_code = 204 620return response 621 622 623@app.route("/picture/<int:id>/get-annotations") 624@app.route("/api/picture/<int:id>/api/get-annotations") 625def get_annotations(id): 626resource = db.session.get(PictureResource, id) 627if resource is None: 628flask.abort(404) 629 630regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 631 632regions_json = [] 633 634for region in regions: 635regions_json.append({ 636"object": region.object_id, 637"type": region.json["type"], 638"shape": region.json["shape"], 639}) 640 641return flask.jsonify(regions_json) 642 643 644@app.route("/picture/<int:id>/delete") 645def delete_picture(id): 646resource = db.session.get(PictureResource, id) 647if resource is None: 648flask.abort(404) 649 650current_user = db.session.get(User, flask.session.get("username")) 651if current_user is None: 652flask.abort(401) 653 654if resource.author != current_user and not current_user.admin: 655flask.abort(403) 656 657PictureLicence.query.filter_by(resource=resource).delete() 658PictureRegion.query.filter_by(resource=resource).delete() 659PictureInGallery.query.filter_by(resource=resource).delete() 660if resource.replaces: 661resource.replaces.replaced_by = None 662if resource.replaced_by: 663resource.replaced_by.replaces = None 664resource.copied_from = None 665for copy in resource.copies: 666copy.copied_from = None 667db.session.delete(resource) 668db.session.commit() 669 670return flask.redirect("/") 671 672 673@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 674def mark_picture_replacement(id): 675resource = db.session.get(PictureResource, id) 676if resource is None: 677flask.abort(404) 678 679current_user = db.session.get(User, flask.session.get("username")) 680if current_user is None: 681flask.abort(401) 682 683if resource.copied_from.author != current_user and not current_user.admin: 684flask.abort(403) 685 686resource.copied_from.replaced_by = resource 687resource.replaces = resource.copied_from 688 689db.session.commit() 690 691return flask.redirect("/picture/" + str(resource.copied_from.id)) 692 693 694@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 695def remove_picture_replacement(id): 696resource = db.session.get(PictureResource, id) 697if resource is None: 698flask.abort(404) 699 700current_user = db.session.get(User, flask.session.get("username")) 701if current_user is None: 702flask.abort(401) 703 704if resource.author != current_user and not current_user.admin: 705flask.abort(403) 706 707resource.replaced_by.replaces = None 708resource.replaced_by = None 709 710db.session.commit() 711 712return flask.redirect("/picture/" + str(resource.id)) 713 714 715@app.route("/picture/<int:id>/edit-metadata") 716def edit_picture(id): 717resource = db.session.get(PictureResource, id) 718if resource is None: 719flask.abort(404) 720 721current_user = db.session.get(User, flask.session.get("username")) 722if current_user is None: 723flask.abort(401) 724 725if resource.author != current_user and not current_user.admin: 726flask.abort(403) 727 728licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 729Licence.title).all() 730 731types = PictureNature.query.all() 732 733return flask.render_template("edit-picture.html", resource=resource, licences=licences, 734types=types, 735PictureLicence=PictureLicence) 736 737 738@app.route("/picture/<int:id>/rate", methods=["POST"]) 739def rate_picture(id): 740resource = db.session.get(PictureResource, id) 741if resource is None: 742flask.abort(404) 743 744current_user = db.session.get(User, flask.session.get("username")) 745if current_user is None: 746flask.abort(401) 747 748rating = int(flask.request.form.get("rating")) 749 750if not rating: 751# Delete the existing rating 752if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 753db.session.delete(PictureRating.query.filter_by(resource=resource, 754user=current_user).first()) 755db.session.commit() 756 757return flask.redirect("/picture/" + str(resource.id)) 758 759if not 1 <= rating <= 5: 760flask.flash("Invalid rating") 761return flask.redirect("/picture/" + str(resource.id)) 762 763if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 764PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 765else: 766# Create a new rating 767db.session.add(PictureRating(resource, current_user, rating)) 768 769db.session.commit() 770 771return flask.redirect("/picture/" + str(resource.id)) 772 773 774@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 775def edit_picture_post(id): 776resource = db.session.get(PictureResource, id) 777if resource is None: 778flask.abort(404) 779 780current_user = db.session.get(User, flask.session.get("username")) 781if current_user is None: 782flask.abort(401) 783 784if resource.author != current_user and not current_user.admin: 785flask.abort(403) 786 787title = flask.request.form["title"] 788description = flask.request.form["description"] 789origin_url = flask.request.form["origin_url"] 790licence_ids = flask.request.form.getlist("licence") 791nature_id = flask.request.form["nature"] 792 793if not title: 794flask.flash("Enter a title") 795return flask.redirect(flask.request.url) 796 797if not description: 798description = "" 799 800if not nature_id: 801flask.flash("Select a picture type") 802return flask.redirect(flask.request.url) 803 804if not licence_ids: 805flask.flash("Select licences") 806return flask.redirect(flask.request.url) 807 808licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 809if not any(licence.free for licence in licences): 810flask.flash("Select at least one free licence") 811return flask.redirect(flask.request.url) 812 813resource.title = title 814resource.description = description 815resource.origin_url = origin_url 816for licence_id in licence_ids: 817joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 818db.session.add(joiner) 819resource.nature = db.session.get(PictureNature, nature_id) 820 821db.session.commit() 822 823return flask.redirect("/picture/" + str(resource.id)) 824 825 826@app.route("/picture/<int:id>/copy") 827def copy_picture(id): 828resource = db.session.get(PictureResource, id) 829if resource is None: 830flask.abort(404) 831 832current_user = db.session.get(User, flask.session.get("username")) 833if current_user is None: 834flask.abort(401) 835 836new_resource = PictureResource(resource.title, current_user, resource.description, 837resource.origin_url, 838[licence.licence_id for licence in resource.licences], 839resource.file_format, 840resource.nature) 841 842for region in resource.regions: 843db.session.add(PictureRegion(region.json, new_resource, region.object)) 844 845db.session.commit() 846 847# Create a hard link for the new picture 848old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 849new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 850os.link(old_path, new_path) 851 852new_resource.width = resource.width 853new_resource.height = resource.height 854new_resource.copied_from = resource 855 856db.session.commit() 857 858return flask.redirect("/picture/" + str(new_resource.id)) 859 860 861@app.route("/gallery/<int:id>/") 862def gallery(id): 863gallery = db.session.get(Gallery, id) 864if gallery is None: 865flask.abort(404) 866 867current_user = db.session.get(User, flask.session.get("username")) 868 869have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 870 871return flask.render_template("gallery.html", gallery=gallery, 872have_permission=have_permission) 873 874 875@app.route("/create-gallery") 876def create_gallery(): 877if "username" not in flask.session: 878flask.flash("Log in to create galleries.") 879return flask.redirect("/accounts") 880 881return flask.render_template("create-gallery.html") 882 883 884@app.route("/create-gallery", methods=["POST"]) 885def create_gallery_post(): 886if not flask.session.get("username"): 887flask.abort(401) 888 889if not flask.request.form.get("title"): 890flask.flash("Enter a title") 891return flask.redirect(flask.request.url) 892 893description = flask.request.form.get("description", "") 894 895gallery = Gallery(flask.request.form["title"], description, 896db.session.get(User, flask.session["username"])) 897db.session.add(gallery) 898db.session.commit() 899 900return flask.redirect("/gallery/" + str(gallery.id)) 901 902 903@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 904def gallery_add_picture(id): 905gallery = db.session.get(Gallery, id) 906if gallery is None: 907flask.abort(404) 908 909if "username" not in flask.session: 910flask.abort(401) 911 912if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 913flask.abort(403) 914 915picture_id = flask.request.form.get("picture_id") 916if "/" in picture_id: # also allow full URLs 917picture_id = picture_id.rstrip("/").rpartition("/")[1] 918if not picture_id: 919flask.flash("Select a picture") 920return flask.redirect("/gallery/" + str(gallery.id)) 921picture_id = int(picture_id) 922 923picture = db.session.get(PictureResource, picture_id) 924if picture is None: 925flask.flash("Invalid picture") 926return flask.redirect("/gallery/" + str(gallery.id)) 927 928if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 929flask.flash("This picture is already in the gallery") 930return flask.redirect("/gallery/" + str(gallery.id)) 931 932db.session.add(PictureInGallery(picture, gallery)) 933 934db.session.commit() 935 936return flask.redirect("/gallery/" + str(gallery.id)) 937 938 939@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 940def gallery_remove_picture(id): 941gallery = db.session.get(Gallery, id) 942if gallery is None: 943flask.abort(404) 944 945if "username" not in flask.session: 946flask.abort(401) 947 948current_user = db.session.get(User, flask.session.get("username")) 949 950if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 951flask.abort(403) 952 953picture_id = int(flask.request.form.get("picture_id")) 954 955picture = db.session.get(PictureResource, picture_id) 956if picture is None: 957flask.flash("Invalid picture") 958return flask.redirect("/gallery/" + str(gallery.id)) 959 960picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 961gallery=gallery).first() 962if picture_in_gallery is None: 963flask.flash("This picture isn't in the gallery") 964return flask.redirect("/gallery/" + str(gallery.id)) 965 966db.session.delete(picture_in_gallery) 967 968db.session.commit() 969 970return flask.redirect("/gallery/" + str(gallery.id)) 971 972 973@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 974def gallery_add_from_query(id): 975gallery = db.session.get(Gallery, id) 976if gallery is None: 977flask.abort(404) 978 979if "username" not in flask.session: 980flask.abort(401) 981 982if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 983flask.abort(403) 984 985query_yaml = flask.request.form.get("query", "") 986 987yaml_parser = yaml.YAML() 988query_data = yaml_parser.load(query_yaml) or {} 989query = get_picture_query(query_data) 990 991pictures = query.all() 992 993count = 0 994 995for picture in pictures: 996if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 997db.session.add(PictureInGallery(picture, gallery)) 998count += 1 999 1000db.session.commit() 1001 1002flask.flash(f"Added {count} pictures to the gallery") 1003 1004return flask.redirect("/gallery/" + str(gallery.id)) 1005 1006 1007@app.route("/gallery/<int:id>/users") 1008def gallery_users(id): 1009gallery = db.session.get(Gallery, id) 1010if gallery is None: 1011flask.abort(404) 1012 1013current_user = db.session.get(User, flask.session.get("username")) 1014have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1015 1016return flask.render_template("gallery-users.html", gallery=gallery, 1017have_permission=have_permission) 1018 1019 1020@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1021def gallery_add_user(id): 1022gallery = db.session.get(Gallery, id) 1023if gallery is None: 1024flask.abort(404) 1025 1026current_user = db.session.get(User, flask.session.get("username")) 1027if current_user is None: 1028flask.abort(401) 1029 1030if current_user != gallery.owner and not current_user.admin: 1031flask.abort(403) 1032 1033username = flask.request.form.get("username") 1034if username == gallery.owner_name: 1035flask.flash("The owner is already in the gallery") 1036return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1037 1038user = db.session.get(User, username) 1039if user is None: 1040flask.flash("User not found") 1041return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1042 1043if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1044flask.flash("User is already in the gallery") 1045return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1046 1047db.session.add(UserInGallery(user, gallery)) 1048 1049db.session.commit() 1050 1051return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1052 1053 1054@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1055def gallery_remove_user(id): 1056gallery = db.session.get(Gallery, id) 1057if gallery is None: 1058flask.abort(404) 1059 1060current_user = db.session.get(User, flask.session.get("username")) 1061if current_user is None: 1062flask.abort(401) 1063 1064if current_user != gallery.owner and not current_user.admin: 1065flask.abort(403) 1066 1067username = flask.request.form.get("username") 1068user = db.session.get(User, username) 1069if user is None: 1070flask.flash("User not found") 1071return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1072 1073user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1074if user_in_gallery is None: 1075flask.flash("User is not in the gallery") 1076return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1077 1078db.session.delete(user_in_gallery) 1079 1080db.session.commit() 1081 1082return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1083 1084 1085class APIError(Exception): 1086def __init__(self, status_code, message): 1087self.status_code = status_code 1088self.message = message 1089 1090 1091def get_picture_query(query_data): 1092query = db.session.query(PictureResource) 1093 1094requirement_conditions = { 1095"has_object": lambda value: PictureResource.regions.any( 1096PictureRegion.object_id.in_(value)), 1097"nature": lambda value: PictureResource.nature_id.in_(value), 1098"licence": lambda value: PictureResource.licences.any( 1099PictureLicence.licence_id.in_(value)), 1100"author": lambda value: PictureResource.author_name.in_(value), 1101"title": lambda value: PictureResource.title.ilike(value), 1102"description": lambda value: PictureResource.description.ilike(value), 1103"origin_url": lambda value: db.func.lower(db.func.substr( 1104PictureResource.origin_url, 1105db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1106)).in_(value), 1107"above_width": lambda value: PictureResource.width >= value, 1108"below_width": lambda value: PictureResource.width <= value, 1109"above_height": lambda value: PictureResource.height >= value, 1110"below_height": lambda value: PictureResource.height <= value, 1111"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1112value), 1113"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1114value), 1115"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1116} 1117if "want" in query_data: 1118for i in query_data["want"]: 1119if len(i) != 1: 1120raise APIError(400, "Each requirement must have exactly one key") 1121requirement, value = list(i.items())[0] 1122if requirement not in requirement_conditions: 1123raise APIError(400, f"Unknown requirement type: {requirement}") 1124 1125condition = requirement_conditions[requirement] 1126query = query.filter(condition(value)) 1127if "exclude" in query_data: 1128for i in query_data["exclude"]: 1129if len(i) != 1: 1130raise APIError(400, "Each exclusion 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 not query_data.get("include_obsolete", False): 1138query = query.filter(PictureResource.replaced_by_id.is_(None)) 1139 1140return query 1141 1142 1143@app.route("/query-pictures") 1144def graphical_query_pictures(): 1145return flask.render_template("graphical-query-pictures.html") 1146 1147 1148@app.route("/query-pictures-results") 1149def graphical_query_pictures_results(): 1150query_yaml = flask.request.args.get("query", "") 1151yaml_parser = yaml.YAML() 1152query_data = yaml_parser.load(query_yaml) or {} 1153try: 1154query = get_picture_query(query_data) 1155except APIError as e: 1156flask.abort(e.status_code) 1157 1158page = int(flask.request.args.get("page", 1)) 1159per_page = int(flask.request.args.get("per_page", 16)) 1160 1161resources = query.paginate(page=page, per_page=per_page) 1162 1163return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1164query=query_yaml, 1165page_number=page, page_length=per_page, 1166num_pages=resources.pages, 1167prev_page=resources.prev_num, next_page=resources.next_num) 1168 1169 1170@app.route("/raw/picture/<int:id>") 1171def raw_picture(id): 1172resource = db.session.get(PictureResource, id) 1173if resource is None: 1174flask.abort(404) 1175 1176response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1177str(resource.id)) 1178response.mimetype = resource.file_format 1179 1180return response 1181 1182 1183@app.route("/object/") 1184def graphical_object_types(): 1185return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1186 1187 1188@app.route("/api/object-types") 1189def object_types(): 1190objects = db.session.query(PictureObject).all() 1191return flask.jsonify({object.id: object.description for object in objects}) 1192 1193 1194@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1195def query_pictures(): 1196offset = int(flask.request.args.get("offset", 0)) 1197limit = int(flask.request.args.get("limit", 16)) 1198ordering = flask.request.args.get("ordering", "date-desc") 1199 1200yaml_parser = yaml.YAML() 1201query_data = yaml_parser.load(flask.request.data) or {} 1202try: 1203query = get_picture_query(query_data) 1204except APIError as e: 1205return flask.jsonify({"error": e.message}), e.status_code 1206 1207match ordering: 1208case "date-desc": 1209query = query.order_by(PictureResource.timestamp.desc()) 1210case "date-asc": 1211query = query.order_by(PictureResource.timestamp.asc()) 1212case "title-asc": 1213query = query.order_by(PictureResource.title.asc()) 1214case "title-desc": 1215query = query.order_by(PictureResource.title.desc()) 1216case "random": 1217query = query.order_by(db.func.random()) 1218case "number-regions-desc": 1219query = query.order_by(db.func.count(PictureResource.regions).desc()) 1220case "number-regions-asc": 1221query = query.order_by(db.func.count(PictureResource.regions).asc()) 1222 1223query = query.offset(offset).limit(limit) 1224resources = query.all() 1225 1226json_response = { 1227"date_generated": datetime.utcnow().timestamp(), 1228"resources": [], 1229"offset": offset, 1230"limit": limit, 1231} 1232 1233json_resources = json_response["resources"] 1234 1235for resource in resources: 1236json_resource = { 1237"id": resource.id, 1238"title": resource.title, 1239"description": resource.description, 1240"timestamp": resource.timestamp.timestamp(), 1241"origin_url": resource.origin_url, 1242"author": resource.author_name, 1243"file_format": resource.file_format, 1244"width": resource.width, 1245"height": resource.height, 1246"nature": resource.nature_id, 1247"licences": [licence.licence_id for licence in resource.licences], 1248"replaces": resource.replaces_id, 1249"replaced_by": resource.replaced_by_id, 1250"regions": [], 1251"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1252} 1253for region in resource.regions: 1254json_resource["regions"].append({ 1255"object": region.object_id, 1256"type": region.json["type"], 1257"shape": region.json["shape"], 1258}) 1259 1260json_resources.append(json_resource) 1261 1262return flask.jsonify(json_response) 1263 1264 1265@app.route("/api/picture/<int:id>/") 1266def api_picture(id): 1267resource = db.session.get(PictureResource, id) 1268if resource is None: 1269flask.abort(404) 1270 1271json_resource = { 1272"id": resource.id, 1273"title": resource.title, 1274"description": resource.description, 1275"timestamp": resource.timestamp.timestamp(), 1276"origin_url": resource.origin_url, 1277"author": resource.author_name, 1278"file_format": resource.file_format, 1279"width": resource.width, 1280"height": resource.height, 1281"nature": resource.nature_id, 1282"licences": [licence.licence_id for licence in resource.licences], 1283"replaces": resource.replaces_id, 1284"replaced_by": resource.replaced_by_id, 1285"regions": [], 1286"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1287} 1288for region in resource.regions: 1289json_resource["regions"].append({ 1290"object": region.object_id, 1291"type": region.json["type"], 1292"shape": region.json["shape"], 1293}) 1294 1295return flask.jsonify(json_resource) 1296 1297 1298@app.route("/api/licence/") 1299def api_licences(): 1300licences = db.session.query(Licence).all() 1301json_licences = { 1302licence.id: { 1303"title": licence.title, 1304"free": licence.free, 1305"pinned": licence.pinned, 1306} for licence in licences 1307} 1308 1309return flask.jsonify(json_licences) 1310 1311 1312@app.route("/api/licence/<id>/") 1313def api_licence(id): 1314licence = db.session.get(Licence, id) 1315if licence is None: 1316flask.abort(404) 1317 1318json_licence = { 1319"id": licence.id, 1320"title": licence.title, 1321"description": licence.description, 1322"info_url": licence.info_url, 1323"legalese_url": licence.url, 1324"free": licence.free, 1325"logo_url": licence.logo_url, 1326"pinned": licence.pinned, 1327} 1328 1329return flask.jsonify(json_licence) 1330 1331 1332@app.route("/api/nature/") 1333def api_natures(): 1334natures = db.session.query(PictureNature).all() 1335json_natures = { 1336nature.id: nature.description for nature in natures 1337} 1338 1339return flask.jsonify(json_natures) 1340 1341 1342@app.route("/api/user/") 1343def api_users(): 1344offset = int(flask.request.args.get("offset", 0)) 1345limit = int(flask.request.args.get("limit", 16)) 1346 1347users = db.session.query(User).offset(offset).limit(limit).all() 1348 1349json_users = { 1350user.username: { 1351"admin": user.admin, 1352} for user in users 1353} 1354 1355return flask.jsonify(json_users) 1356 1357 1358@app.route("/api/user/<username>/") 1359def api_user(username): 1360user = db.session.get(User, username) 1361if user is None: 1362flask.abort(404) 1363 1364json_user = { 1365"username": user.username, 1366"admin": user.admin, 1367"joined": user.joined_timestamp.timestamp(), 1368} 1369 1370return flask.jsonify(json_user) 1371 1372 1373@app.route("/api/login", methods=["POST"]) 1374def api_login(): 1375username = flask.request.json["username"] 1376password = flask.request.json["password"] 1377 1378user = db.session.get(User, username) 1379 1380if user is None: 1381return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1382 1383if not bcrypt.check_password_hash(user.password_hashed, password): 1384return flask.jsonify({"error": "Incorrect password"}), 401 1385 1386flask.session["username"] = username 1387 1388return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1389 1390 1391@app.route("/api/logout", methods=["POST"]) 1392def api_logout(): 1393flask.session.pop("username", None) 1394return flask.jsonify({"message": "You have been logged out."}) 1395 1396 1397@app.route("/api/upload", methods=["POST"]) 1398def api_upload(): 1399if "username" not in flask.session: 1400return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1401 1402json_ = json.loads(flask.request.form["json"]) 1403title = json_["title"] 1404description = json_.get("description", "") 1405origin_url = json_.get("origin_url", "") 1406author = db.session.get(User, flask.session["username"]) 1407licence_ids = json_["licence"] 1408nature_id = json_["nature"] 1409file = flask.request.files["file"] 1410 1411if not file or not file.filename: 1412return flask.jsonify({"error": "An image file must be uploaded"}), 400 1413 1414if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1415return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1416 1417if not title: 1418return flask.jsonify({"error": "Give a title"}), 400 1419 1420if not description: 1421description = "" 1422 1423if not nature_id: 1424return flask.jsonify({"error": "Give a picture type"}), 400 1425 1426if not licence_ids: 1427return flask.jsonify({"error": "Give licences"}), 400 1428 1429licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1430if not any(licence.free for licence in licences): 1431return flask.jsonify({"error": "Use at least one free licence"}), 400 1432 1433resource = PictureResource(title, author, description, origin_url, licence_ids, 1434file.mimetype, 1435db.session.get(PictureNature, nature_id)) 1436db.session.add(resource) 1437db.session.commit() 1438file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1439pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1440resource.width, resource.height = pil_image.size 1441db.session.commit() 1442 1443if json_.get("annotations"): 1444try: 1445resource.put_annotations(json_["annotations"]) 1446db.session.commit() 1447except json.JSONDecodeError: 1448return flask.jsonify({"error": "Invalid annotations"}), 400 1449 1450return flask.jsonify({"message": "Picture uploaded successfully"}) 1451 1452 1453@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1454def api_update_picture(id): 1455resource = db.session.get(PictureResource, id) 1456if resource is None: 1457return flask.jsonify({"error": "Picture not found"}), 404 1458current_user = db.session.get(User, flask.session.get("username")) 1459if current_user is None: 1460return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1461if resource.author != current_user and not current_user.admin: 1462return flask.jsonify({"error": "You are not the author of this picture"}), 403 1463 1464title = flask.request.json.get("title", resource.title) 1465description = flask.request.json.get("description", resource.description) 1466origin_url = flask.request.json.get("origin_url", resource.origin_url) 1467licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1468nature_id = flask.request.json.get("nature", resource.nature_id) 1469 1470if not title: 1471return flask.jsonify({"error": "Give a title"}), 400 1472 1473if not description: 1474description = "" 1475 1476if not nature_id: 1477return flask.jsonify({"error": "Give a picture type"}), 400 1478 1479if not licence_ids: 1480return flask.jsonify({"error": "Give licences"}), 400 1481 1482licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1483 1484if not any(licence.free for licence in licences): 1485return flask.jsonify({"error": "Use at least one free licence"}), 400 1486 1487resource.title = title 1488resource.description = description 1489resource.origin_url = origin_url 1490resource.licences = licences 1491resource.nature = db.session.get(PictureNature, nature_id) 1492 1493db.session.commit() 1494 1495return flask.jsonify({"message": "Picture updated successfully"}) 1496 1497