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() 594PictureInGallery.query.filter_by(resource=resource).delete() 595if resource.replaces: 596resource.replaces.replaced_by = None 597if resource.replaced_by: 598resource.replaced_by.replaces = None 599resource.copied_from = None 600for copy in resource.copies: 601copy.copied_from = None 602db.session.delete(resource) 603db.session.commit() 604 605return flask.redirect("/") 606 607 608@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 609def mark_picture_replacement(id): 610resource = db.session.get(PictureResource, id) 611if resource is None: 612flask.abort(404) 613 614current_user = db.session.get(User, flask.session.get("username")) 615if current_user is None: 616flask.abort(401) 617 618if resource.copied_from.author != current_user and not current_user.admin: 619flask.abort(403) 620 621resource.copied_from.replaced_by = resource 622resource.replaces = resource.copied_from 623 624db.session.commit() 625 626return flask.redirect("/picture/" + str(resource.copied_from.id)) 627 628 629@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 630def remove_picture_replacement(id): 631resource = db.session.get(PictureResource, id) 632if resource is None: 633flask.abort(404) 634 635current_user = db.session.get(User, flask.session.get("username")) 636if current_user is None: 637flask.abort(401) 638 639if resource.author != current_user and not current_user.admin: 640flask.abort(403) 641 642resource.replaced_by.replaces = None 643resource.replaced_by = None 644 645db.session.commit() 646 647return flask.redirect("/picture/" + str(resource.id)) 648 649 650@app.route("/picture/<int:id>/edit-metadata") 651def edit_picture(id): 652resource = db.session.get(PictureResource, id) 653if resource is None: 654flask.abort(404) 655 656current_user = db.session.get(User, flask.session.get("username")) 657if current_user is None: 658flask.abort(401) 659 660if resource.author != current_user and not current_user.admin: 661flask.abort(403) 662 663licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 664 665types = PictureNature.query.all() 666 667return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types, 668PictureLicence=PictureLicence) 669 670 671@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 672def edit_picture_post(id): 673resource = db.session.get(PictureResource, id) 674if resource is None: 675flask.abort(404) 676 677current_user = db.session.get(User, flask.session.get("username")) 678if current_user is None: 679flask.abort(401) 680 681if resource.author != current_user and not current_user.admin: 682flask.abort(403) 683 684title = flask.request.form["title"] 685description = flask.request.form["description"] 686origin_url = flask.request.form["origin_url"] 687licence_ids = flask.request.form.getlist("licence") 688nature_id = flask.request.form["nature"] 689 690if not title: 691flask.flash("Enter a title") 692return flask.redirect(flask.request.url) 693 694if not description: 695description = "" 696 697if not nature_id: 698flask.flash("Select a picture type") 699return flask.redirect(flask.request.url) 700 701if not licence_ids: 702flask.flash("Select licences") 703return flask.redirect(flask.request.url) 704 705licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 706if not any(licence.free for licence in licences): 707flask.flash("Select at least one free licence") 708return flask.redirect(flask.request.url) 709 710resource.title = title 711resource.description = description 712resource.origin_url = origin_url 713for licence_id in licence_ids: 714joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 715db.session.add(joiner) 716resource.nature = db.session.get(PictureNature, nature_id) 717 718db.session.commit() 719 720return flask.redirect("/picture/" + str(resource.id)) 721 722 723@app.route("/picture/<int:id>/copy") 724def copy_picture(id): 725resource = db.session.get(PictureResource, id) 726if resource is None: 727flask.abort(404) 728 729current_user = db.session.get(User, flask.session.get("username")) 730if current_user is None: 731flask.abort(401) 732 733new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url, 734[licence.licence_id for licence in resource.licences], resource.file_format, 735resource.nature) 736 737for region in resource.regions: 738db.session.add(PictureRegion(region.json, new_resource, region.object)) 739 740db.session.commit() 741 742# Create a hard link for the new picture 743old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 744new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 745os.link(old_path, new_path) 746 747new_resource.width = resource.width 748new_resource.height = resource.height 749new_resource.copied_from = resource 750 751db.session.commit() 752 753return flask.redirect("/picture/" + str(new_resource.id)) 754 755 756@app.route("/gallery/<int:id>/") 757def gallery(id): 758gallery = db.session.get(Gallery, id) 759if gallery is None: 760flask.abort(404) 761 762current_user = db.session.get(User, flask.session.get("username")) 763 764have_permission = current_user and (current_user == gallery.owner or current_user.admin) 765 766return flask.render_template("gallery.html", gallery=gallery, have_permission=have_permission) 767 768 769@app.route("/create-gallery") 770def create_gallery(): 771if "username" not in flask.session: 772flask.flash("Log in to create galleries.") 773return flask.redirect("/accounts") 774 775return flask.render_template("create-gallery.html") 776 777 778@app.route("/create-gallery", methods=["POST"]) 779def create_gallery_post(): 780if not flask.session.get("username"): 781flask.abort(401) 782 783if not flask.request.form.get("title"): 784flask.flash("Enter a title") 785return flask.redirect(flask.request.url) 786 787description = flask.request.form.get("description", "") 788 789gallery = Gallery(flask.request.form["title"], description, db.session.get(User, flask.session["username"])) 790db.session.add(gallery) 791db.session.commit() 792 793return flask.redirect("/gallery/" + str(gallery.id)) 794 795 796@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 797def gallery_add_picture(id): 798gallery = db.session.get(Gallery, id) 799if gallery is None: 800flask.abort(404) 801 802if "username" not in flask.session: 803flask.abort(401) 804 805if flask.session["username"] != gallery.owner_name and not db.session.get(User, flask.session["username"]).admin: 806flask.abort(403) 807 808picture_id = flask.request.form.get("picture_id") 809if "/" in picture_id: # also allow full URLs 810picture_id = picture_id.rstrip("/").rpartition("/")[1] 811if not picture_id: 812flask.flash("Select a picture") 813return flask.redirect("/gallery/" + str(gallery.id)) 814picture_id = int(picture_id) 815 816picture = db.session.get(PictureResource, picture_id) 817if picture is None: 818flask.flash("Invalid picture") 819return flask.redirect("/gallery/" + str(gallery.id)) 820 821if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 822flask.flash("This picture is already in the gallery") 823return flask.redirect("/gallery/" + str(gallery.id)) 824 825db.session.add(PictureInGallery(picture, gallery)) 826 827db.session.commit() 828 829return flask.redirect("/gallery/" + str(gallery.id)) 830 831 832@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 833def gallery_remove_picture(id): 834gallery = db.session.get(Gallery, id) 835if gallery is None: 836flask.abort(404) 837 838if "username" not in flask.session: 839flask.abort(401) 840 841if flask.session["username"] != gallery.owner_name and not db.session.get(User, flask.session["username"]).admin: 842flask.abort(403) 843 844picture_id = int(flask.request.form.get("picture_id")) 845 846picture = db.session.get(PictureResource, picture_id) 847if picture is None: 848flask.flash("Invalid picture") 849return flask.redirect("/gallery/" + str(gallery.id)) 850 851picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 852if picture_in_gallery is None: 853flask.flash("This picture isn't in the gallery") 854return flask.redirect("/gallery/" + str(gallery.id)) 855 856db.session.delete(picture_in_gallery) 857 858db.session.commit() 859 860return flask.redirect("/gallery/" + str(gallery.id)) 861 862 863def get_picture_query(query_data): 864query = db.session.query(PictureResource) 865 866requirement_conditions = { 867"has_object": lambda value: PictureResource.regions.any( 868PictureRegion.object_id.in_(value)), 869"nature": lambda value: PictureResource.nature_id.in_(value), 870"licence": lambda value: PictureResource.licences.any( 871PictureLicence.licence_id.in_(value)), 872"author": lambda value: PictureResource.author_name.in_(value), 873"title": lambda value: PictureResource.title.ilike(value), 874"description": lambda value: PictureResource.description.ilike(value), 875"origin_url": lambda value: db.func.lower(db.func.substr( 876PictureResource.origin_url, 877db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 878)).in_(value), 879"above_width": lambda value: PictureResource.width >= value, 880"below_width": lambda value: PictureResource.width <= value, 881"above_height": lambda value: PictureResource.height >= value, 882"below_height": lambda value: PictureResource.height <= value, 883"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 884value), 885"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 886value) 887} 888if "want" in query_data: 889for i in query_data["want"]: 890requirement, value = list(i.items())[0] 891condition = requirement_conditions.get(requirement) 892if condition: 893query = query.filter(condition(value)) 894if "exclude" in query_data: 895for i in query_data["exclude"]: 896requirement, value = list(i.items())[0] 897condition = requirement_conditions.get(requirement) 898if condition: 899query = query.filter(~condition(value)) 900if not query_data.get("include_obsolete", False): 901query = query.filter(PictureResource.replaced_by_id.is_(None)) 902 903return query 904 905 906@app.route("/query-pictures") 907def graphical_query_pictures(): 908return flask.render_template("graphical-query-pictures.html") 909 910 911@app.route("/query-pictures-results") 912def graphical_query_pictures_results(): 913query_yaml = flask.request.args.get("query", "") 914yaml_parser = yaml.YAML() 915query_data = yaml_parser.load(query_yaml) or {} 916query = get_picture_query(query_data) 917 918page = int(flask.request.args.get("page", 1)) 919per_page = int(flask.request.args.get("per_page", 16)) 920 921resources = query.paginate(page=page, per_page=per_page) 922 923return flask.render_template("graphical-query-pictures-results.html", resources=resources, query=query_yaml, 924page_number=page, page_length=per_page, num_pages=resources.pages, 925prev_page=resources.prev_num, next_page=resources.next_num) 926 927 928@app.route("/raw/picture/<int:id>") 929def raw_picture(id): 930resource = db.session.get(PictureResource, id) 931if resource is None: 932flask.abort(404) 933 934response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 935response.mimetype = resource.file_format 936 937return response 938 939 940@app.route("/object/") 941def graphical_object_types(): 942return flask.render_template("object-types.html", objects=PictureObject.query.all()) 943 944 945@app.route("/api/object-types") 946def object_types(): 947objects = db.session.query(PictureObject).all() 948return flask.jsonify({object.id: object.description for object in objects}) 949 950 951@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 952def query_pictures(): 953offset = int(flask.request.args.get("offset", 0)) 954limit = int(flask.request.args.get("limit", 16)) 955ordering = flask.request.args.get("ordering", "date-desc") 956 957yaml_parser = yaml.YAML() 958query_data = yaml_parser.load(flask.request.data) or {} 959query = get_picture_query(query_data) 960 961match ordering: 962case "date-desc": 963query = query.order_by(PictureResource.timestamp.desc()) 964case "date-asc": 965query = query.order_by(PictureResource.timestamp.asc()) 966case "title-asc": 967query = query.order_by(PictureResource.title.asc()) 968case "title-desc": 969query = query.order_by(PictureResource.title.desc()) 970case "random": 971query = query.order_by(db.func.random()) 972case "number-regions-desc": 973query = query.order_by(db.func.count(PictureResource.regions).desc()) 974case "number-regions-asc": 975query = query.order_by(db.func.count(PictureResource.regions).asc()) 976 977query = query.offset(offset).limit(limit) 978resources = query.all() 979 980json_response = { 981"date_generated": datetime.utcnow().timestamp(), 982"resources": [], 983"offset": offset, 984"limit": limit, 985} 986 987json_resources = json_response["resources"] 988 989for resource in resources: 990json_resource = { 991"id": resource.id, 992"title": resource.title, 993"description": resource.description, 994"timestamp": resource.timestamp.timestamp(), 995"origin_url": resource.origin_url, 996"author": resource.author_name, 997"file_format": resource.file_format, 998"width": resource.width, 999"height": resource.height, 1000"nature": resource.nature_id, 1001"licences": [licence.licence_id for licence in resource.licences], 1002"replaces": resource.replaces_id, 1003"replaced_by": resource.replaced_by_id, 1004"regions": [], 1005"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1006} 1007for region in resource.regions: 1008json_resource["regions"].append({ 1009"object": region.object_id, 1010"type": region.json["type"], 1011"shape": region.json["shape"], 1012}) 1013 1014json_resources.append(json_resource) 1015 1016return flask.jsonify(json_response) 1017 1018 1019@app.route("/api/picture/<int:id>/") 1020def api_picture(id): 1021resource = db.session.get(PictureResource, id) 1022if resource is None: 1023flask.abort(404) 1024 1025json_resource = { 1026"id": resource.id, 1027"title": resource.title, 1028"description": resource.description, 1029"timestamp": resource.timestamp.timestamp(), 1030"origin_url": resource.origin_url, 1031"author": resource.author_name, 1032"file_format": resource.file_format, 1033"width": resource.width, 1034"height": resource.height, 1035"nature": resource.nature_id, 1036"licences": [licence.licence_id for licence in resource.licences], 1037"replaces": resource.replaces_id, 1038"replaced_by": resource.replaced_by_id, 1039"regions": [], 1040"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1041} 1042for region in resource.regions: 1043json_resource["regions"].append({ 1044"object": region.object_id, 1045"type": region.json["type"], 1046"shape": region.json["shape"], 1047}) 1048 1049return flask.jsonify(json_resource) 1050 1051 1052@app.route("/api/licence/") 1053def api_licences(): 1054licences = db.session.query(Licence).all() 1055json_licences = { 1056licence.id: { 1057"title": licence.title, 1058"free": licence.free, 1059"pinned": licence.pinned, 1060} for licence in licences 1061} 1062 1063return flask.jsonify(json_licences) 1064 1065 1066@app.route("/api/licence/<id>/") 1067def api_licence(id): 1068licence = db.session.get(Licence, id) 1069if licence is None: 1070flask.abort(404) 1071 1072json_licence = { 1073"id": licence.id, 1074"title": licence.title, 1075"description": licence.description, 1076"info_url": licence.info_url, 1077"legalese_url": licence.url, 1078"free": licence.free, 1079"logo_url": licence.logo_url, 1080"pinned": licence.pinned, 1081} 1082 1083return flask.jsonify(json_licence) 1084 1085 1086@app.route("/api/nature/") 1087def api_natures(): 1088natures = db.session.query(PictureNature).all() 1089json_natures = { 1090nature.id: nature.description for nature in natures 1091} 1092 1093return flask.jsonify(json_natures) 1094 1095 1096@app.route("/api/user/") 1097def api_users(): 1098offset = int(flask.request.args.get("offset", 0)) 1099limit = int(flask.request.args.get("limit", 16)) 1100 1101users = db.session.query(User).offset(offset).limit(limit).all() 1102 1103json_users = { 1104user.username: { 1105"admin": user.admin, 1106} for user in users 1107} 1108 1109return flask.jsonify(json_users) 1110 1111 1112@app.route("/api/user/<username>/") 1113def api_user(username): 1114user = db.session.get(User, username) 1115if user is None: 1116flask.abort(404) 1117 1118json_user = { 1119"username": user.username, 1120"admin": user.admin, 1121"joined": user.joined_timestamp.timestamp(), 1122} 1123 1124return flask.jsonify(json_user) 1125 1126