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