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