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.orm import backref 14import sqlalchemy.dialects.postgresql 15from os import path 16import os 17from urllib.parse import urlencode 18import mimetypes 19import ruamel.yaml as yaml 20 21from PIL import Image 22from sqlalchemy.orm.persistence import post_update 23 24import config 25import markdown 26 27 28app = flask.Flask(__name__) 29bcrypt = Bcrypt(app) 30 31 32app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 33app.config["SECRET_KEY"] = config.DB_PASSWORD 34 35 36db = SQLAlchemy(app) 37migrate = Migrate(app, db) 38 39 40@app.template_filter("split") 41def split(value, separator=None, maxsplit=-1): 42return value.split(separator, maxsplit) 43 44 45@app.template_filter("median") 46def median(value): 47value = list(value) # prevent generators 48return sorted(value)[len(value) // 2] 49 50 51@app.template_filter("set") 52def set_filter(value): 53return set(value) 54 55 56@app.template_global() 57def modify_query(**new_values): 58args = flask.request.args.copy() 59for key, value in new_values.items(): 60args[key] = value 61 62return f"{flask.request.path}?{urlencode(args)}" 63 64 65@app.context_processor 66def default_variables(): 67return { 68"current_user": db.session.get(User, flask.session.get("username")), 69} 70 71 72with app.app_context(): 73class User(db.Model): 74username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 75password_hashed = db.Column(db.String(60), nullable=False) 76admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 77pictures = db.relationship("PictureResource", back_populates="author") 78joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 79galleries = db.relationship("Gallery", back_populates="owner") 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, nullable=False) # brief description of its permissions and restrictions 96info_url = db.Column(db.String(1024), nullable=False) # the URL to a page with general information about the licence 97url = db.Column(db.String(1024), nullable=True) # the URL to a page with the full text of the licence and more information 98pictures = db.relationship("PictureLicence", back_populates="licence") 99free = db.Column(db.Boolean, nullable=False, default=False) # whether the licence is free or not 100logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 101pinned = db.Column(db.Boolean, nullable=False, default=False) # whether the licence should be shown at the top of the list 102 103def __init__(self, id, title, description, info_url, url, free, logo_url=None, pinned=False): 104self.id = id 105self.title = title 106self.description = description 107self.info_url = info_url 108self.url = url 109self.free = free 110self.logo_url = logo_url 111self.pinned = pinned 112 113 114class PictureLicence(db.Model): 115id = db.Column(db.Integer, primary_key=True, autoincrement=True) 116 117resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 118licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 119 120resource = db.relationship("PictureResource", back_populates="licences") 121licence = db.relationship("Licence", back_populates="pictures") 122 123def __init__(self, resource, licence): 124self.resource = resource 125self.licence = licence 126 127 128class Resource(db.Model): 129__abstract__ = True 130 131id = db.Column(db.Integer, primary_key=True, autoincrement=True) 132title = db.Column(db.UnicodeText, nullable=False) 133description = db.Column(db.UnicodeText, nullable=False) 134timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 135origin_url = db.Column(db.String(2048), nullable=True) # should be left empty if it's original or the source is unknown but public domain 136 137 138class PictureNature(db.Model): 139# Examples: 140# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 141# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 142# "screen-photo", "pattern", "collage", "ai", and so on 143id = db.Column(db.String(64), primary_key=True) 144description = db.Column(db.UnicodeText, nullable=False) 145resources = db.relationship("PictureResource", back_populates="nature") 146 147def __init__(self, id, description): 148self.id = id 149self.description = description 150 151 152class PictureObjectInheritance(db.Model): 153parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 154primary_key=True) 155child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 156primary_key=True) 157 158parent = db.relationship("PictureObject", foreign_keys=[parent_id], 159back_populates="child_links") 160child = db.relationship("PictureObject", foreign_keys=[child_id], 161back_populates="parent_links") 162 163def __init__(self, parent, child): 164self.parent = parent 165self.child = child 166 167 168class PictureObject(db.Model): 169id = db.Column(db.String(64), primary_key=True) 170description = db.Column(db.UnicodeText, nullable=False) 171 172child_links = db.relationship("PictureObjectInheritance", 173foreign_keys=[PictureObjectInheritance.parent_id], 174back_populates="parent") 175parent_links = db.relationship("PictureObjectInheritance", 176foreign_keys=[PictureObjectInheritance.child_id], 177back_populates="child") 178 179def __init__(self, id, description): 180self.id = id 181self.description = description 182 183 184class PictureRegion(db.Model): 185# This is for picture region annotations 186id = db.Column(db.Integer, primary_key=True, autoincrement=True) 187json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 188 189resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 190object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 191 192resource = db.relationship("PictureResource", backref="regions") 193object = db.relationship("PictureObject", backref="regions") 194 195def __init__(self, json, resource, object): 196self.json = json 197self.resource = resource 198self.object = object 199 200 201class PictureResource(Resource): 202# This is only for bitmap pictures. Vectors will be stored under a different model 203# File name is the ID in the picture directory under data, without an extension 204file_format = db.Column(db.String(64), nullable=False) # MIME type 205width = db.Column(db.Integer, nullable=False) 206height = db.Column(db.Integer, nullable=False) 207nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 208author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 209author = db.relationship("User", back_populates="pictures") 210 211nature = db.relationship("PictureNature", back_populates="resources") 212 213replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 214replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 215nullable=True) 216 217replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 218foreign_keys=[replaces_id], back_populates="replaced_by", 219post_update=True) 220replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 221foreign_keys=[replaced_by_id], post_update=True) 222 223copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 224copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 225backref="copies", foreign_keys=[copied_from_id]) 226 227licences = db.relationship("PictureLicence", back_populates="resource") 228galleries = db.relationship("PictureInGallery", back_populates="resource") 229 230def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None): 231self.title = title 232self.author = author 233self.description = description 234self.origin_url = origin_url 235self.file_format = mime 236self.width = self.height = 0 237self.nature = nature 238db.session.add(self) 239db.session.commit() 240for licence_id in licence_ids: 241joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 242db.session.add(joiner) 243 244def put_annotations(self, json): 245# Delete all previous annotations 246db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 247 248for region in json: 249object_id = region["object"] 250picture_object = db.session.get(PictureObject, object_id) 251 252region_data = { 253"type": region["type"], 254"shape": region["shape"], 255} 256 257region_row = PictureRegion(region_data, self, picture_object) 258db.session.add(region_row) 259 260 261class PictureInGallery(db.Model): 262id = db.Column(db.Integer, primary_key=True, autoincrement=True) 263resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 264gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 265 266resource = db.relationship("PictureResource") 267gallery = db.relationship("Gallery") 268 269def __init__(self, resource, gallery): 270self.resource = resource 271self.gallery = gallery 272 273 274class Gallery(db.Model): 275id = db.Column(db.Integer, primary_key=True, autoincrement=True) 276title = db.Column(db.UnicodeText, nullable=False) 277description = db.Column(db.UnicodeText, nullable=False) 278pictures = db.relationship("PictureInGallery", back_populates="gallery") 279owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 280owner = db.relationship("User", back_populates="galleries") 281 282def __init__(self, title, description, owner): 283self.title = title 284self.description = description 285self.owner = owner 286 287 288 289@app.route("/") 290def index(): 291return flask.render_template("home.html", resources=PictureResource.query.order_by(db.func.random()).limit(10).all()) 292 293 294@app.route("/accounts/") 295def accounts(): 296return flask.render_template("login.html") 297 298 299@app.route("/login", methods=["POST"]) 300def login(): 301username = flask.request.form["username"] 302password = flask.request.form["password"] 303 304user = db.session.get(User, username) 305 306if user is None: 307flask.flash("This username is not registered.") 308return flask.redirect("/accounts") 309 310if not bcrypt.check_password_hash(user.password_hashed, password): 311flask.flash("Incorrect password.") 312return flask.redirect("/accounts") 313 314flask.flash("You have been logged in.") 315 316flask.session["username"] = username 317return flask.redirect("/") 318 319 320@app.route("/logout") 321def logout(): 322flask.session.pop("username", None) 323flask.flash("You have been logged out.") 324return flask.redirect("/") 325 326 327@app.route("/signup", methods=["POST"]) 328def signup(): 329username = flask.request.form["username"] 330password = flask.request.form["password"] 331 332if db.session.get(User, username) is not None: 333flask.flash("This username is already taken.") 334return flask.redirect("/accounts") 335 336if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 337flask.flash("Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 338return flask.redirect("/accounts") 339 340if len(username) < 3 or len(username) > 32: 341flask.flash("Usernames must be between 3 and 32 characters long.") 342return flask.redirect("/accounts") 343 344if len(password) < 6: 345flask.flash("Passwords must be at least 6 characters long.") 346return flask.redirect("/accounts") 347 348user = User(username, password) 349db.session.add(user) 350db.session.commit() 351 352flask.session["username"] = username 353 354flask.flash("You have been registered and logged in.") 355 356return flask.redirect("/") 357 358 359@app.route("/profile", defaults={"username": None}) 360@app.route("/profile/<username>") 361def profile(username): 362if username is None: 363if "username" in flask.session: 364return flask.redirect("/profile/" + flask.session["username"]) 365else: 366flask.flash("Please log in to perform this action.") 367return flask.redirect("/accounts") 368 369user = db.session.get(User, username) 370if user is None: 371flask.abort(404) 372 373return flask.render_template("profile.html", user=user) 374 375 376@app.route("/object/<id>") 377def has_object(id): 378object_ = db.session.get(PictureObject, id) 379if object_ is None: 380flask.abort(404) 381 382query = db.session.query(PictureResource).join(PictureRegion).filter(PictureRegion.object_id == id) 383 384page = int(flask.request.args.get("page", 1)) 385per_page = int(flask.request.args.get("per_page", 16)) 386 387resources = query.paginate(page=page, per_page=per_page) 388 389return flask.render_template("object.html", object=object_, resources=resources, page_number=page, 390page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num, 391next_page=resources.next_num, PictureRegion=PictureRegion) 392 393 394@app.route("/upload") 395def upload(): 396if "username" not in flask.session: 397flask.flash("Log in to upload pictures.") 398return flask.redirect("/accounts") 399 400licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 401 402types = PictureNature.query.all() 403 404return flask.render_template("upload.html", licences=licences, types=types) 405 406 407@app.route("/upload", methods=["POST"]) 408def upload_post(): 409title = flask.request.form["title"] 410description = flask.request.form["description"] 411origin_url = flask.request.form["origin_url"] 412author = db.session.get(User, flask.session.get("username")) 413licence_ids = flask.request.form.getlist("licence") 414nature_id = flask.request.form["nature"] 415 416if author is None: 417flask.abort(401) 418 419file = flask.request.files["file"] 420 421if not file or not file.filename: 422flask.flash("Select a file") 423return flask.redirect(flask.request.url) 424 425if not file.mimetype.startswith("image/"): 426flask.flash("Only images are supported") 427return flask.redirect(flask.request.url) 428 429if not title: 430flask.flash("Enter a title") 431return flask.redirect(flask.request.url) 432 433if not description: 434description = "" 435 436if not nature_id: 437flask.flash("Select a picture type") 438return flask.redirect(flask.request.url) 439 440if not licence_ids: 441flask.flash("Select licences") 442return flask.redirect(flask.request.url) 443 444licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 445if not any(licence.free for licence in licences): 446flask.flash("Select at least one free licence") 447return flask.redirect(flask.request.url) 448 449resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype, 450db.session.get(PictureNature, nature_id)) 451db.session.add(resource) 452db.session.commit() 453file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 454pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 455resource.width, resource.height = pil_image.size 456db.session.commit() 457 458if flask.request.form.get("annotations"): 459try: 460resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 461db.session.commit() 462except json.JSONDecodeError: 463flask.flash("Invalid annotations") 464 465flask.flash("Picture uploaded successfully") 466 467return flask.redirect("/picture/" + str(resource.id)) 468 469 470@app.route("/picture/<int:id>/") 471def picture(id): 472resource = db.session.get(PictureResource, id) 473if resource is None: 474flask.abort(404) 475 476image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 477 478current_user = db.session.get(User, flask.session.get("username")) 479have_permission = current_user and (current_user == resource.author or current_user.admin) 480 481return flask.render_template("picture.html", resource=resource, 482file_extension=mimetypes.guess_extension(resource.file_format), 483size=image.size, copies=resource.copies, have_permission=have_permission) 484 485 486 487@app.route("/picture/<int:id>/annotate") 488def annotate_picture(id): 489resource = db.session.get(PictureResource, id) 490if resource is None: 491flask.abort(404) 492 493current_user = db.session.get(User, flask.session.get("username")) 494if current_user is None: 495flask.abort(401) 496if resource.author != current_user and not current_user.admin: 497flask.abort(403) 498 499return flask.render_template("picture-annotation.html", resource=resource, 500file_extension=mimetypes.guess_extension(resource.file_format)) 501 502 503@app.route("/picture/<int:id>/put-annotations-form") 504def put_annotations_form(id): 505resource = db.session.get(PictureResource, id) 506if resource is None: 507flask.abort(404) 508 509current_user = db.session.get(User, flask.session.get("username")) 510if current_user is None: 511flask.abort(401) 512 513if resource.author != current_user and not current_user.admin: 514flask.abort(403) 515 516return flask.render_template("put-annotations-form.html", resource=resource) 517 518 519@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 520def put_annotations_form_post(id): 521resource = db.session.get(PictureResource, id) 522if resource is None: 523flask.abort(404) 524 525current_user = db.session.get(User, flask.session.get("username")) 526if current_user is None: 527flask.abort(401) 528 529if resource.author != current_user and not current_user.admin: 530flask.abort(403) 531 532resource.put_annotations(json.loads(flask.request.form["annotations"])) 533 534db.session.commit() 535 536return flask.redirect("/picture/" + str(resource.id)) 537 538 539 540@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 541def save_annotations(id): 542resource = db.session.get(PictureResource, id) 543if resource is None: 544flask.abort(404) 545 546current_user = db.session.get(User, flask.session.get("username")) 547if resource.author != current_user and not current_user.admin: 548flask.abort(403) 549 550resource.put_annotations(flask.request.json) 551 552db.session.commit() 553 554response = flask.make_response() 555response.status_code = 204 556return response 557 558 559@app.route("/picture/<int:id>/get-annotations") 560def get_annotations(id): 561resource = db.session.get(PictureResource, id) 562if resource is None: 563flask.abort(404) 564 565regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 566 567regions_json = [] 568 569for region in regions: 570regions_json.append({ 571"object": region.object_id, 572"type": region.json["type"], 573"shape": region.json["shape"], 574}) 575 576return flask.jsonify(regions_json) 577 578 579@app.route("/picture/<int:id>/delete") 580def delete_picture(id): 581resource = db.session.get(PictureResource, id) 582if resource is None: 583flask.abort(404) 584 585current_user = db.session.get(User, flask.session.get("username")) 586if current_user is None: 587flask.abort(401) 588 589if resource.author != current_user and not current_user.admin: 590flask.abort(403) 591 592PictureLicence.query.filter_by(resource=resource).delete() 593PictureRegion.query.filter_by(resource=resource).delete() 594if resource.replaces: 595resource.replaces.replaced_by = None 596if resource.replaced_by: 597resource.replaced_by.replaces = None 598resource.copied_from = None 599for copy in resource.copies: 600copy.copied_from = None 601db.session.delete(resource) 602db.session.commit() 603 604return flask.redirect("/") 605 606 607@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 608def mark_picture_replacement(id): 609resource = db.session.get(PictureResource, id) 610if resource is None: 611flask.abort(404) 612 613current_user = db.session.get(User, flask.session.get("username")) 614if current_user is None: 615flask.abort(401) 616 617if resource.copied_from.author != current_user and not current_user.admin: 618flask.abort(403) 619 620resource.copied_from.replaced_by = resource 621resource.replaces = resource.copied_from 622 623db.session.commit() 624 625return flask.redirect("/picture/" + str(resource.copied_from.id)) 626 627 628@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 629def remove_picture_replacement(id): 630resource = db.session.get(PictureResource, id) 631if resource is None: 632flask.abort(404) 633 634current_user = db.session.get(User, flask.session.get("username")) 635if current_user is None: 636flask.abort(401) 637 638if resource.author != current_user and not current_user.admin: 639flask.abort(403) 640 641resource.replaced_by.replaces = None 642resource.replaced_by = None 643 644db.session.commit() 645 646return flask.redirect("/picture/" + str(resource.id)) 647 648 649@app.route("/picture/<int:id>/edit-metadata") 650def edit_picture(id): 651resource = db.session.get(PictureResource, id) 652if resource is None: 653flask.abort(404) 654 655current_user = db.session.get(User, flask.session.get("username")) 656if current_user is None: 657flask.abort(401) 658 659if resource.author != current_user and not current_user.admin: 660flask.abort(403) 661 662licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 663 664types = PictureNature.query.all() 665 666return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types, 667PictureLicence=PictureLicence) 668 669 670@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 671def edit_picture_post(id): 672resource = db.session.get(PictureResource, id) 673if resource is None: 674flask.abort(404) 675 676current_user = db.session.get(User, flask.session.get("username")) 677if current_user is None: 678flask.abort(401) 679 680if resource.author != current_user and not current_user.admin: 681flask.abort(403) 682 683title = flask.request.form["title"] 684description = flask.request.form["description"] 685origin_url = flask.request.form["origin_url"] 686licence_ids = flask.request.form.getlist("licence") 687nature_id = flask.request.form["nature"] 688 689if not title: 690flask.flash("Enter a title") 691return flask.redirect(flask.request.url) 692 693if not description: 694description = "" 695 696if not nature_id: 697flask.flash("Select a picture type") 698return flask.redirect(flask.request.url) 699 700if not licence_ids: 701flask.flash("Select licences") 702return flask.redirect(flask.request.url) 703 704licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 705if not any(licence.free for licence in licences): 706flask.flash("Select at least one free licence") 707return flask.redirect(flask.request.url) 708 709resource.title = title 710resource.description = description 711resource.origin_url = origin_url 712for licence_id in licence_ids: 713joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 714db.session.add(joiner) 715resource.nature = db.session.get(PictureNature, nature_id) 716 717db.session.commit() 718 719return flask.redirect("/picture/" + str(resource.id)) 720 721 722@app.route("/picture/<int:id>/copy") 723def copy_picture(id): 724resource = db.session.get(PictureResource, id) 725if resource is None: 726flask.abort(404) 727 728current_user = db.session.get(User, flask.session.get("username")) 729if current_user is None: 730flask.abort(401) 731 732new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url, 733[licence.licence_id for licence in resource.licences], resource.file_format, 734resource.nature) 735 736for region in resource.regions: 737db.session.add(PictureRegion(region.json, new_resource, region.object)) 738 739db.session.commit() 740 741# Create a hard link for the new picture 742old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 743new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 744os.link(old_path, new_path) 745 746new_resource.width = resource.width 747new_resource.height = resource.height 748new_resource.copied_from = resource 749 750db.session.commit() 751 752return flask.redirect("/picture/" + str(new_resource.id)) 753 754 755@app.route("/gallery/<int:id>/") 756def gallery(id): 757gallery = db.session.get(Gallery, id) 758if gallery is None: 759flask.abort(404) 760 761return flask.render_template("gallery.html", gallery=gallery) 762 763 764@app.route("/create-gallery") 765def create_gallery(): 766if "username" not in flask.session: 767flask.flash("Log in to create galleries.") 768return flask.redirect("/accounts") 769 770return flask.render_template("create-gallery.html") 771 772 773@app.route("/create-gallery", methods=["POST"]) 774def create_gallery_post(): 775if not flask.session.get("username"): 776flask.abort(401) 777 778if not flask.request.form.get("title"): 779flask.flash("Enter a title") 780return flask.redirect(flask.request.url) 781 782description = flask.request.form.get("description", "") 783 784gallery = Gallery(flask.request.form["title"], description, db.session.get(User, flask.session["username"])) 785db.session.add(gallery) 786db.session.commit() 787 788return flask.redirect("/gallery/" + str(gallery.id)) 789 790 791@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 792def gallery_add_picture(id): 793gallery = db.session.get(Gallery, id) 794if gallery is None: 795flask.abort(404) 796 797if "username" not in flask.session: 798flask.abort(401) 799 800if flask.session["username"] != gallery.owner_name and not db.session.get(User, flask.session["username"]).admin: 801flask.abort(403) 802 803picture_id = flask.request.form.get("picture_id") 804if "/" in picture_id: # also allow full URLs 805picture_id = picture_id.rstrip("/").rpartition("/")[1] 806if not picture_id: 807flask.flash("Select a picture") 808return flask.redirect("/gallery/" + str(gallery.id)) 809picture_id = int(picture_id) 810 811picture = db.session.get(PictureResource, picture_id) 812if picture is None: 813flask.flash("Invalid picture") 814return flask.redirect("/gallery/" + str(gallery.id)) 815 816if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 817flask.flash("This picture is already in the gallery") 818return flask.redirect("/gallery/" + str(gallery.id)) 819 820db.session.add(PictureInGallery(picture, gallery)) 821 822db.session.commit() 823 824return flask.redirect("/gallery/" + str(gallery.id)) 825 826 827def get_picture_query(query_data): 828query = db.session.query(PictureResource) 829 830requirement_conditions = { 831"has_object": lambda value: PictureResource.regions.any( 832PictureRegion.object_id.in_(value)), 833"nature": lambda value: PictureResource.nature_id.in_(value), 834"licence": lambda value: PictureResource.licences.any( 835PictureLicence.licence_id.in_(value)), 836"author": lambda value: PictureResource.author_name.in_(value), 837"title": lambda value: PictureResource.title.ilike(value), 838"description": lambda value: PictureResource.description.ilike(value), 839"origin_url": lambda value: db.func.lower(db.func.substr( 840PictureResource.origin_url, 841db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 842)).in_(value), 843"above_width": lambda value: PictureResource.width >= value, 844"below_width": lambda value: PictureResource.width <= value, 845"above_height": lambda value: PictureResource.height >= value, 846"below_height": lambda value: PictureResource.height <= value, 847"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 848value), 849"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 850value) 851} 852if "want" in query_data: 853for i in query_data["want"]: 854requirement, value = list(i.items())[0] 855condition = requirement_conditions.get(requirement) 856if condition: 857query = query.filter(condition(value)) 858if "exclude" in query_data: 859for i in query_data["exclude"]: 860requirement, value = list(i.items())[0] 861condition = requirement_conditions.get(requirement) 862if condition: 863query = query.filter(~condition(value)) 864if not query_data.get("include_obsolete", False): 865query = query.filter(PictureResource.replaced_by_id.is_(None)) 866 867return query 868 869 870@app.route("/query-pictures") 871def graphical_query_pictures(): 872return flask.render_template("graphical-query-pictures.html") 873 874 875@app.route("/query-pictures-results") 876def graphical_query_pictures_results(): 877query_yaml = flask.request.args.get("query", "") 878yaml_parser = yaml.YAML() 879query_data = yaml_parser.load(query_yaml) or {} 880query = get_picture_query(query_data) 881 882page = int(flask.request.args.get("page", 1)) 883per_page = int(flask.request.args.get("per_page", 16)) 884 885resources = query.paginate(page=page, per_page=per_page) 886 887return flask.render_template("graphical-query-pictures-results.html", resources=resources, query=query_yaml, 888page_number=page, page_length=per_page, num_pages=resources.pages, 889prev_page=resources.prev_num, next_page=resources.next_num) 890 891 892@app.route("/raw/picture/<int:id>") 893def raw_picture(id): 894resource = db.session.get(PictureResource, id) 895if resource is None: 896flask.abort(404) 897 898response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 899response.mimetype = resource.file_format 900 901return response 902 903 904@app.route("/object/") 905def graphical_object_types(): 906return flask.render_template("object-types.html", objects=PictureObject.query.all()) 907 908 909@app.route("/api/object-types") 910def object_types(): 911objects = db.session.query(PictureObject).all() 912return flask.jsonify({object.id: object.description for object in objects}) 913 914 915@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 916def query_pictures(): 917offset = int(flask.request.args.get("offset", 0)) 918limit = int(flask.request.args.get("limit", 16)) 919ordering = flask.request.args.get("ordering", "date-desc") 920 921yaml_parser = yaml.YAML() 922query_data = yaml_parser.load(flask.request.data) or {} 923query = get_picture_query(query_data) 924 925match ordering: 926case "date-desc": 927query = query.order_by(PictureResource.timestamp.desc()) 928case "date-asc": 929query = query.order_by(PictureResource.timestamp.asc()) 930case "title-asc": 931query = query.order_by(PictureResource.title.asc()) 932case "title-desc": 933query = query.order_by(PictureResource.title.desc()) 934case "random": 935query = query.order_by(db.func.random()) 936case "number-regions-desc": 937query = query.order_by(db.func.count(PictureResource.regions).desc()) 938case "number-regions-asc": 939query = query.order_by(db.func.count(PictureResource.regions).asc()) 940 941query = query.offset(offset).limit(limit) 942resources = query.all() 943 944json_response = { 945"date_generated": datetime.utcnow().timestamp(), 946"resources": [], 947"offset": offset, 948"limit": limit, 949} 950 951json_resources = json_response["resources"] 952 953for resource in resources: 954json_resource = { 955"id": resource.id, 956"title": resource.title, 957"description": resource.description, 958"timestamp": resource.timestamp.timestamp(), 959"origin_url": resource.origin_url, 960"author": resource.author_name, 961"file_format": resource.file_format, 962"width": resource.width, 963"height": resource.height, 964"nature": resource.nature_id, 965"licences": [licence.licence_id for licence in resource.licences], 966"replaces": resource.replaces_id, 967"replaced_by": resource.replaced_by_id, 968"regions": [], 969"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 970} 971for region in resource.regions: 972json_resource["regions"].append({ 973"object": region.object_id, 974"type": region.json["type"], 975"shape": region.json["shape"], 976}) 977 978json_resources.append(json_resource) 979 980return flask.jsonify(json_response) 981 982 983@app.route("/api/picture/<int:id>/") 984def api_picture(id): 985resource = db.session.get(PictureResource, id) 986if resource is None: 987flask.abort(404) 988 989json_resource = { 990"id": resource.id, 991"title": resource.title, 992"description": resource.description, 993"timestamp": resource.timestamp.timestamp(), 994"origin_url": resource.origin_url, 995"author": resource.author_name, 996"file_format": resource.file_format, 997"width": resource.width, 998"height": resource.height, 999"nature": resource.nature_id, 1000"licences": [licence.licence_id for licence in resource.licences], 1001"replaces": resource.replaces_id, 1002"replaced_by": resource.replaced_by_id, 1003"regions": [], 1004"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1005} 1006for region in resource.regions: 1007json_resource["regions"].append({ 1008"object": region.object_id, 1009"type": region.json["type"], 1010"shape": region.json["shape"], 1011}) 1012 1013return flask.jsonify(json_resource) 1014 1015 1016@app.route("/api/licence/") 1017def api_licences(): 1018licences = db.session.query(Licence).all() 1019json_licences = { 1020licence.id: { 1021"title": licence.title, 1022"free": licence.free, 1023"pinned": licence.pinned, 1024} for licence in licences 1025} 1026 1027return flask.jsonify(json_licences) 1028 1029 1030@app.route("/api/licence/<id>/") 1031def api_licence(id): 1032licence = db.session.get(Licence, id) 1033if licence is None: 1034flask.abort(404) 1035 1036json_licence = { 1037"id": licence.id, 1038"title": licence.title, 1039"description": licence.description, 1040"info_url": licence.info_url, 1041"legalese_url": licence.url, 1042"free": licence.free, 1043"logo_url": licence.logo_url, 1044"pinned": licence.pinned, 1045} 1046 1047return flask.jsonify(json_licence) 1048 1049 1050@app.route("/api/nature/") 1051def api_natures(): 1052natures = db.session.query(PictureNature).all() 1053json_natures = { 1054nature.id: nature.description for nature in natures 1055} 1056 1057return flask.jsonify(json_natures) 1058 1059 1060@app.route("/api/user/") 1061def api_users(): 1062offset = int(flask.request.args.get("offset", 0)) 1063limit = int(flask.request.args.get("limit", 16)) 1064 1065users = db.session.query(User).offset(offset).limit(limit).all() 1066 1067json_users = { 1068user.username: { 1069"admin": user.admin, 1070} for user in users 1071} 1072 1073return flask.jsonify(json_users) 1074 1075 1076@app.route("/api/user/<username>/") 1077def api_user(username): 1078user = db.session.get(User, username) 1079if user is None: 1080flask.abort(404) 1081 1082json_user = { 1083"username": user.username, 1084"admin": user.admin, 1085"joined": user.joined_timestamp.timestamp(), 1086} 1087 1088return flask.jsonify(json_user) 1089 1090