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>/users") 902def gallery_users(id): 903gallery = db.session.get(Gallery, id) 904if gallery is None: 905flask.abort(404) 906 907current_user = db.session.get(User, flask.session.get("username")) 908have_permission = current_user and (current_user == gallery.owner or current_user.admin) 909 910return flask.render_template("gallery-users.html", gallery=gallery, 911have_permission=have_permission) 912 913 914@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 915def gallery_add_user(id): 916gallery = db.session.get(Gallery, id) 917if gallery is None: 918flask.abort(404) 919 920current_user = db.session.get(User, flask.session.get("username")) 921if current_user is None: 922flask.abort(401) 923 924if current_user != gallery.owner and not current_user.admin: 925flask.abort(403) 926 927username = flask.request.form.get("username") 928if username == gallery.owner_name: 929flask.flash("The owner is already in the gallery") 930return flask.redirect("/gallery/" + str(gallery.id) + "/users") 931 932user = db.session.get(User, username) 933if user is None: 934flask.flash("User not found") 935return flask.redirect("/gallery/" + str(gallery.id) + "/users") 936 937if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 938flask.flash("User is already in the gallery") 939return flask.redirect("/gallery/" + str(gallery.id) + "/users") 940 941db.session.add(UserInGallery(user, gallery)) 942 943db.session.commit() 944 945return flask.redirect("/gallery/" + str(gallery.id) + "/users") 946 947 948@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 949def gallery_remove_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") 962user = db.session.get(User, username) 963if user is None: 964flask.flash("User not found") 965return flask.redirect("/gallery/" + str(gallery.id) + "/users") 966 967user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 968if user_in_gallery is None: 969flask.flash("User is not in the gallery") 970return flask.redirect("/gallery/" + str(gallery.id) + "/users") 971 972db.session.delete(user_in_gallery) 973 974db.session.commit() 975 976return flask.redirect("/gallery/" + str(gallery.id) + "/users") 977 978 979def get_picture_query(query_data): 980query = db.session.query(PictureResource) 981 982requirement_conditions = { 983"has_object": lambda value: PictureResource.regions.any( 984PictureRegion.object_id.in_(value)), 985"nature": lambda value: PictureResource.nature_id.in_(value), 986"licence": lambda value: PictureResource.licences.any( 987PictureLicence.licence_id.in_(value)), 988"author": lambda value: PictureResource.author_name.in_(value), 989"title": lambda value: PictureResource.title.ilike(value), 990"description": lambda value: PictureResource.description.ilike(value), 991"origin_url": lambda value: db.func.lower(db.func.substr( 992PictureResource.origin_url, 993db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 994)).in_(value), 995"above_width": lambda value: PictureResource.width >= value, 996"below_width": lambda value: PictureResource.width <= value, 997"above_height": lambda value: PictureResource.height >= value, 998"below_height": lambda value: PictureResource.height <= value, 999"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1000value), 1001"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1002value) 1003} 1004if "want" in query_data: 1005for i in query_data["want"]: 1006requirement, value = list(i.items())[0] 1007condition = requirement_conditions.get(requirement) 1008if condition: 1009query = query.filter(condition(value)) 1010if "exclude" in query_data: 1011for i in query_data["exclude"]: 1012requirement, value = list(i.items())[0] 1013condition = requirement_conditions.get(requirement) 1014if condition: 1015query = query.filter(~condition(value)) 1016if not query_data.get("include_obsolete", False): 1017query = query.filter(PictureResource.replaced_by_id.is_(None)) 1018 1019return query 1020 1021 1022@app.route("/query-pictures") 1023def graphical_query_pictures(): 1024return flask.render_template("graphical-query-pictures.html") 1025 1026 1027@app.route("/query-pictures-results") 1028def graphical_query_pictures_results(): 1029query_yaml = flask.request.args.get("query", "") 1030yaml_parser = yaml.YAML() 1031query_data = yaml_parser.load(query_yaml) or {} 1032query = get_picture_query(query_data) 1033 1034page = int(flask.request.args.get("page", 1)) 1035per_page = int(flask.request.args.get("per_page", 16)) 1036 1037resources = query.paginate(page=page, per_page=per_page) 1038 1039return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1040query=query_yaml, 1041page_number=page, page_length=per_page, 1042num_pages=resources.pages, 1043prev_page=resources.prev_num, next_page=resources.next_num) 1044 1045 1046@app.route("/raw/picture/<int:id>") 1047def raw_picture(id): 1048resource = db.session.get(PictureResource, id) 1049if resource is None: 1050flask.abort(404) 1051 1052response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1053str(resource.id)) 1054response.mimetype = resource.file_format 1055 1056return response 1057 1058 1059@app.route("/object/") 1060def graphical_object_types(): 1061return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1062 1063 1064@app.route("/api/object-types") 1065def object_types(): 1066objects = db.session.query(PictureObject).all() 1067return flask.jsonify({object.id: object.description for object in objects}) 1068 1069 1070@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1071def query_pictures(): 1072offset = int(flask.request.args.get("offset", 0)) 1073limit = int(flask.request.args.get("limit", 16)) 1074ordering = flask.request.args.get("ordering", "date-desc") 1075 1076yaml_parser = yaml.YAML() 1077query_data = yaml_parser.load(flask.request.data) or {} 1078query = get_picture_query(query_data) 1079 1080match ordering: 1081case "date-desc": 1082query = query.order_by(PictureResource.timestamp.desc()) 1083case "date-asc": 1084query = query.order_by(PictureResource.timestamp.asc()) 1085case "title-asc": 1086query = query.order_by(PictureResource.title.asc()) 1087case "title-desc": 1088query = query.order_by(PictureResource.title.desc()) 1089case "random": 1090query = query.order_by(db.func.random()) 1091case "number-regions-desc": 1092query = query.order_by(db.func.count(PictureResource.regions).desc()) 1093case "number-regions-asc": 1094query = query.order_by(db.func.count(PictureResource.regions).asc()) 1095 1096query = query.offset(offset).limit(limit) 1097resources = query.all() 1098 1099json_response = { 1100"date_generated": datetime.utcnow().timestamp(), 1101"resources": [], 1102"offset": offset, 1103"limit": limit, 1104} 1105 1106json_resources = json_response["resources"] 1107 1108for resource in resources: 1109json_resource = { 1110"id": resource.id, 1111"title": resource.title, 1112"description": resource.description, 1113"timestamp": resource.timestamp.timestamp(), 1114"origin_url": resource.origin_url, 1115"author": resource.author_name, 1116"file_format": resource.file_format, 1117"width": resource.width, 1118"height": resource.height, 1119"nature": resource.nature_id, 1120"licences": [licence.licence_id for licence in resource.licences], 1121"replaces": resource.replaces_id, 1122"replaced_by": resource.replaced_by_id, 1123"regions": [], 1124"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1125} 1126for region in resource.regions: 1127json_resource["regions"].append({ 1128"object": region.object_id, 1129"type": region.json["type"], 1130"shape": region.json["shape"], 1131}) 1132 1133json_resources.append(json_resource) 1134 1135return flask.jsonify(json_response) 1136 1137 1138@app.route("/api/picture/<int:id>/") 1139def api_picture(id): 1140resource = db.session.get(PictureResource, id) 1141if resource is None: 1142flask.abort(404) 1143 1144json_resource = { 1145"id": resource.id, 1146"title": resource.title, 1147"description": resource.description, 1148"timestamp": resource.timestamp.timestamp(), 1149"origin_url": resource.origin_url, 1150"author": resource.author_name, 1151"file_format": resource.file_format, 1152"width": resource.width, 1153"height": resource.height, 1154"nature": resource.nature_id, 1155"licences": [licence.licence_id for licence in resource.licences], 1156"replaces": resource.replaces_id, 1157"replaced_by": resource.replaced_by_id, 1158"regions": [], 1159"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1160} 1161for region in resource.regions: 1162json_resource["regions"].append({ 1163"object": region.object_id, 1164"type": region.json["type"], 1165"shape": region.json["shape"], 1166}) 1167 1168return flask.jsonify(json_resource) 1169 1170 1171@app.route("/api/licence/") 1172def api_licences(): 1173licences = db.session.query(Licence).all() 1174json_licences = { 1175licence.id: { 1176"title": licence.title, 1177"free": licence.free, 1178"pinned": licence.pinned, 1179} for licence in licences 1180} 1181 1182return flask.jsonify(json_licences) 1183 1184 1185@app.route("/api/licence/<id>/") 1186def api_licence(id): 1187licence = db.session.get(Licence, id) 1188if licence is None: 1189flask.abort(404) 1190 1191json_licence = { 1192"id": licence.id, 1193"title": licence.title, 1194"description": licence.description, 1195"info_url": licence.info_url, 1196"legalese_url": licence.url, 1197"free": licence.free, 1198"logo_url": licence.logo_url, 1199"pinned": licence.pinned, 1200} 1201 1202return flask.jsonify(json_licence) 1203 1204 1205@app.route("/api/nature/") 1206def api_natures(): 1207natures = db.session.query(PictureNature).all() 1208json_natures = { 1209nature.id: nature.description for nature in natures 1210} 1211 1212return flask.jsonify(json_natures) 1213 1214 1215@app.route("/api/user/") 1216def api_users(): 1217offset = int(flask.request.args.get("offset", 0)) 1218limit = int(flask.request.args.get("limit", 16)) 1219 1220users = db.session.query(User).offset(offset).limit(limit).all() 1221 1222json_users = { 1223user.username: { 1224"admin": user.admin, 1225} for user in users 1226} 1227 1228return flask.jsonify(json_users) 1229 1230 1231@app.route("/api/user/<username>/") 1232def api_user(username): 1233user = db.session.get(User, username) 1234if user is None: 1235flask.abort(404) 1236 1237json_user = { 1238"username": user.username, 1239"admin": user.admin, 1240"joined": user.joined_timestamp.timestamp(), 1241} 1242 1243return flask.jsonify(json_user) 1244