app.py
Python script, ASCII text executable
1import json 2import os 3import mimetypes 4import flask 5import ruamel.yaml as yaml 6import sqlalchemy.dialects.postgresql 7import config 8import markdown 9 10from datetime import datetime 11from os import path 12from flask_sqlalchemy import SQLAlchemy 13from flask_bcrypt import Bcrypt 14from flask_migrate import Migrate, current 15from urllib.parse import urlencode 16from PIL import Image 17from sqlalchemy.sql.functions import current_user 18 19app = flask.Flask(__name__) 20bcrypt = Bcrypt(app) 21 22app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 23app.config["SECRET_KEY"] = config.DB_PASSWORD 24 25db = SQLAlchemy(app) 26migrate = Migrate(app, db) 27 28 29@app.template_filter("split") 30def split(value, separator=None, maxsplit=-1): 31return value.split(separator, maxsplit) 32 33 34@app.template_filter("median") 35def median(value): 36value = list(value) # prevent generators 37return sorted(value)[len(value) // 2] 38 39 40@app.template_filter("set") 41def set_filter(value): 42return set(value) 43 44 45@app.template_global() 46def modify_query(**new_values): 47args = flask.request.args.copy() 48for key, value in new_values.items(): 49args[key] = value 50 51return f"{flask.request.path}?{urlencode(args)}" 52 53 54@app.context_processor 55def default_variables(): 56return { 57"current_user": db.session.get(User, flask.session.get("username")), 58} 59 60 61with app.app_context(): 62class User(db.Model): 63username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 64password_hashed = db.Column(db.String(60), nullable=False) 65admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 66pictures = db.relationship("PictureResource", back_populates="author") 67joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 68galleries = db.relationship("Gallery", back_populates="owner") 69galleries_joined = db.relationship("UserInGallery", back_populates="user") 70ratings = db.relationship("PictureRating", back_populates="user") 71 72def __init__(self, username, password): 73self.username = username 74self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 75 76@property 77def formatted_name(self): 78if self.admin: 79return self.username + "*" 80return self.username 81 82 83class Licence(db.Model): 84id = db.Column(db.String(64), primary_key=True) # SPDX identifier 85title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 86description = db.Column(db.UnicodeText, 87nullable=False) # brief description of its permissions and restrictions 88info_url = db.Column(db.String(1024), 89nullable=False) # the URL to a page with general information about the licence 90url = db.Column(db.String(1024), 91nullable=True) # the URL to a page with the full text of the licence and more information 92pictures = db.relationship("PictureLicence", back_populates="licence") 93free = db.Column(db.Boolean, nullable=False, 94default=False) # whether the licence is free or not 95logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 96pinned = db.Column(db.Boolean, nullable=False, 97default=False) # whether the licence should be shown at the top of the list 98 99def __init__(self, id, title, description, info_url, url, free, logo_url=None, 100pinned=False): 101self.id = id 102self.title = title 103self.description = description 104self.info_url = info_url 105self.url = url 106self.free = free 107self.logo_url = logo_url 108self.pinned = pinned 109 110 111class PictureLicence(db.Model): 112id = db.Column(db.Integer, primary_key=True, autoincrement=True) 113 114resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 115licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 116 117resource = db.relationship("PictureResource", back_populates="licences") 118licence = db.relationship("Licence", back_populates="pictures") 119 120def __init__(self, resource, licence): 121self.resource = resource 122self.licence = licence 123 124 125class Resource(db.Model): 126__abstract__ = True 127 128id = db.Column(db.Integer, primary_key=True, autoincrement=True) 129title = db.Column(db.UnicodeText, nullable=False) 130description = db.Column(db.UnicodeText, nullable=False) 131timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 132origin_url = db.Column(db.String(2048), 133nullable=True) # should be left empty if it's original or the source is unknown but public domain 134 135 136class PictureNature(db.Model): 137# Examples: 138# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 139# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 140# "screen-photo", "pattern", "collage", "ai", and so on 141id = db.Column(db.String(64), primary_key=True) 142description = db.Column(db.UnicodeText, nullable=False) 143resources = db.relationship("PictureResource", back_populates="nature") 144 145def __init__(self, id, description): 146self.id = id 147self.description = description 148 149 150class PictureObjectInheritance(db.Model): 151parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 152primary_key=True) 153child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 154primary_key=True) 155 156parent = db.relationship("PictureObject", foreign_keys=[parent_id], 157back_populates="child_links") 158child = db.relationship("PictureObject", foreign_keys=[child_id], 159back_populates="parent_links") 160 161def __init__(self, parent, child): 162self.parent = parent 163self.child = child 164 165 166class PictureObject(db.Model): 167id = db.Column(db.String(64), primary_key=True) 168description = db.Column(db.UnicodeText, nullable=False) 169 170child_links = db.relationship("PictureObjectInheritance", 171foreign_keys=[PictureObjectInheritance.parent_id], 172back_populates="parent") 173parent_links = db.relationship("PictureObjectInheritance", 174foreign_keys=[PictureObjectInheritance.child_id], 175back_populates="child") 176 177def __init__(self, id, description, parents): 178self.id = id 179self.description = description 180if parents: 181for parent in parents: 182db.session.add(PictureObjectInheritance(parent, self)) 183 184 185class PictureRegion(db.Model): 186# This is for picture region annotations 187id = db.Column(db.Integer, primary_key=True, autoincrement=True) 188json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 189 190resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 191nullable=False) 192object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 193 194resource = db.relationship("PictureResource", backref="regions") 195object = db.relationship("PictureObject", backref="regions") 196 197def __init__(self, json, resource, object): 198self.json = json 199self.resource = resource 200self.object = object 201 202 203class PictureResource(Resource): 204# This is only for bitmap pictures. Vectors will be stored under a different model 205# File name is the ID in the picture directory under data, without an extension 206file_format = db.Column(db.String(64), nullable=False) # MIME type 207width = db.Column(db.Integer, nullable=False) 208height = db.Column(db.Integer, nullable=False) 209nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 210author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 211author = db.relationship("User", back_populates="pictures") 212 213nature = db.relationship("PictureNature", back_populates="resources") 214 215replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 216replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 217nullable=True) 218 219replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 220foreign_keys=[replaces_id], back_populates="replaced_by", 221post_update=True) 222replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 223foreign_keys=[replaced_by_id], post_update=True) 224 225copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 226nullable=True) 227copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 228backref="copies", foreign_keys=[copied_from_id]) 229 230licences = db.relationship("PictureLicence", back_populates="resource") 231galleries = db.relationship("PictureInGallery", back_populates="resource") 232ratings = db.relationship("PictureRating", back_populates="resource") 233 234def __init__(self, title, author, description, origin_url, licence_ids, mime, 235nature=None): 236self.title = title 237self.author = author 238self.description = description 239self.origin_url = origin_url 240self.file_format = mime 241self.width = self.height = 0 242self.nature = nature 243db.session.add(self) 244db.session.commit() 245for licence_id in licence_ids: 246joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 247db.session.add(joiner) 248 249def put_annotations(self, json): 250# Delete all previous annotations 251db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 252 253for region in json: 254object_id = region["object"] 255picture_object = db.session.get(PictureObject, object_id) 256 257region_data = { 258"type": region["type"], 259"shape": region["shape"], 260} 261 262region_row = PictureRegion(region_data, self, picture_object) 263db.session.add(region_row) 264 265@property 266def average_rating(self): 267if not self.ratings: 268return None 269return db.session.query(db.func.avg(PictureRating.rating)).filter_by(resource=self).scalar() 270 271@property 272def rating_totals(self): 273all_ratings = db.session.query(PictureRating.rating).filter_by(resource=self) 274return {rating: all_ratings.filter_by(rating=rating).count() for rating in range(1, 6)} 275 276@property 277def stars(self): 278if not self.ratings: 279return 0 280average = self.average_rating 281whole_stars = int(average) 282partial_star = average - whole_stars 283 284return [100] * whole_stars + [int(partial_star * 100)] + [0] * (4 - whole_stars) 285 286 287class PictureInGallery(db.Model): 288id = db.Column(db.Integer, primary_key=True, autoincrement=True) 289resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 290nullable=False) 291gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 292 293resource = db.relationship("PictureResource") 294gallery = db.relationship("Gallery") 295 296def __init__(self, resource, gallery): 297self.resource = resource 298self.gallery = gallery 299 300 301class UserInGallery(db.Model): 302id = db.Column(db.Integer, primary_key=True, autoincrement=True) 303username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 304gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 305 306user = db.relationship("User") 307gallery = db.relationship("Gallery") 308 309def __init__(self, user, gallery): 310self.user = user 311self.gallery = gallery 312 313 314class Gallery(db.Model): 315id = db.Column(db.Integer, primary_key=True, autoincrement=True) 316title = db.Column(db.UnicodeText, nullable=False) 317description = db.Column(db.UnicodeText, nullable=False) 318pictures = db.relationship("PictureInGallery", back_populates="gallery") 319owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 320owner = db.relationship("User", back_populates="galleries") 321users = db.relationship("UserInGallery", back_populates="gallery") 322 323def __init__(self, title, description, owner): 324self.title = title 325self.description = description 326self.owner = owner 327 328 329class PictureRating(db.Model): 330id = db.Column(db.Integer, primary_key=True, autoincrement=True) 331resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 332username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 333rating = db.Column(db.Integer, db.CheckConstraint("rating >= 1 AND rating <= 5"), 334nullable=False) 335 336resource = db.relationship("PictureResource", back_populates="ratings") 337user = db.relationship("User", back_populates="ratings") 338 339def __init__(self, resource, user, rating): 340self.resource = resource 341self.user = user 342self.rating = rating 343 344 345@app.route("/") 346def index(): 347return flask.render_template("home.html", resources=PictureResource.query.filter_by(replaced_by=None).order_by( 348db.func.random()).limit(10).all()) 349 350 351@app.route("/info/") 352def usage_guide(): 353with open("help/usage.md") as f: 354return flask.render_template("help.html", content=markdown.markdown2html(f.read())) 355 356 357@app.route("/accounts/") 358def accounts(): 359return flask.render_template("login.html") 360 361 362@app.route("/login", methods=["POST"]) 363def login(): 364username = flask.request.form["username"] 365password = flask.request.form["password"] 366 367user = db.session.get(User, username) 368 369if user is None: 370flask.flash("This username is not registered.") 371return flask.redirect("/accounts") 372 373if not bcrypt.check_password_hash(user.password_hashed, password): 374flask.flash("Incorrect password.") 375return flask.redirect("/accounts") 376 377flask.flash("You have been logged in.") 378 379flask.session["username"] = username 380return flask.redirect("/") 381 382 383@app.route("/logout") 384def logout(): 385flask.session.pop("username", None) 386flask.flash("You have been logged out.") 387return flask.redirect("/") 388 389 390@app.route("/signup", methods=["POST"]) 391def signup(): 392username = flask.request.form["username"] 393password = flask.request.form["password"] 394 395if db.session.get(User, username) is not None: 396flask.flash("This username is already taken.") 397return flask.redirect("/accounts") 398 399if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 400flask.flash( 401"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 402return flask.redirect("/accounts") 403 404if len(username) < 3 or len(username) > 32: 405flask.flash("Usernames must be between 3 and 32 characters long.") 406return flask.redirect("/accounts") 407 408if len(password) < 6: 409flask.flash("Passwords must be at least 6 characters long.") 410return flask.redirect("/accounts") 411 412user = User(username, password) 413db.session.add(user) 414db.session.commit() 415 416flask.session["username"] = username 417 418flask.flash("You have been registered and logged in.") 419 420return flask.redirect("/") 421 422 423@app.route("/profile", defaults={"username": None}) 424@app.route("/profile/<username>") 425def profile(username): 426if username is None: 427if "username" in flask.session: 428return flask.redirect("/profile/" + flask.session["username"]) 429else: 430flask.flash("Please log in to perform this action.") 431return flask.redirect("/accounts") 432 433user = db.session.get(User, username) 434if user is None: 435flask.abort(404) 436 437return flask.render_template("profile.html", user=user) 438 439 440@app.route("/object/<id>") 441def has_object(id): 442object_ = db.session.get(PictureObject, id) 443if object_ is None: 444flask.abort(404) 445 446query = db.session.query(PictureResource).join(PictureRegion).filter( 447PictureRegion.object_id == id) 448 449page = int(flask.request.args.get("page", 1)) 450per_page = int(flask.request.args.get("per_page", 16)) 451 452resources = query.paginate(page=page, per_page=per_page) 453 454return flask.render_template("object.html", object=object_, resources=resources, 455page_number=page, 456page_length=per_page, num_pages=resources.pages, 457prev_page=resources.prev_num, 458next_page=resources.next_num, PictureRegion=PictureRegion) 459 460 461@app.route("/upload") 462def upload(): 463if "username" not in flask.session: 464flask.flash("Log in to upload pictures.") 465return flask.redirect("/accounts") 466 467licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 468Licence.title).all() 469 470types = PictureNature.query.all() 471 472return flask.render_template("upload.html", licences=licences, types=types) 473 474 475@app.route("/upload", methods=["POST"]) 476def upload_post(): 477title = flask.request.form["title"] 478description = flask.request.form["description"] 479origin_url = flask.request.form["origin_url"] 480author = db.session.get(User, flask.session.get("username")) 481licence_ids = flask.request.form.getlist("licence") 482nature_id = flask.request.form["nature"] 483 484if author is None: 485flask.abort(401) 486 487file = flask.request.files["file"] 488 489if not file or not file.filename: 490flask.flash("Select a file") 491return flask.redirect(flask.request.url) 492 493if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 494flask.flash("Only images are supported") 495return flask.redirect(flask.request.url) 496 497if not title: 498flask.flash("Enter a title") 499return flask.redirect(flask.request.url) 500 501if not description: 502description = "" 503 504if not nature_id: 505flask.flash("Select a picture type") 506return flask.redirect(flask.request.url) 507 508if not licence_ids: 509flask.flash("Select licences") 510return flask.redirect(flask.request.url) 511 512licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 513if not any(licence.free for licence in licences): 514flask.flash("Select at least one free licence") 515return flask.redirect(flask.request.url) 516 517resource = PictureResource(title, author, description, origin_url, licence_ids, 518file.mimetype, 519db.session.get(PictureNature, nature_id)) 520db.session.add(resource) 521db.session.commit() 522file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 523pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 524resource.width, resource.height = pil_image.size 525db.session.commit() 526 527if flask.request.form.get("annotations"): 528try: 529resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 530db.session.commit() 531except json.JSONDecodeError: 532flask.flash("Invalid annotations") 533 534flask.flash("Picture uploaded successfully") 535 536return flask.redirect("/picture/" + str(resource.id)) 537 538 539@app.route("/picture/<int:id>/") 540def picture(id): 541resource = db.session.get(PictureResource, id) 542if resource is None: 543flask.abort(404) 544 545image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 546 547current_user = db.session.get(User, flask.session.get("username")) 548have_permission = current_user and (current_user == resource.author or current_user.admin) 549 550own_rating = None 551if current_user: 552own_rating = PictureRating.query.filter_by(resource=resource, user=current_user).first() 553 554return flask.render_template("picture.html", resource=resource, 555file_extension=mimetypes.guess_extension(resource.file_format), 556size=image.size, copies=resource.copies, 557have_permission=have_permission, own_rating=own_rating) 558 559 560@app.route("/picture/<int:id>/annotate") 561def annotate_picture(id): 562resource = db.session.get(PictureResource, id) 563if resource is None: 564flask.abort(404) 565 566current_user = db.session.get(User, flask.session.get("username")) 567if current_user is None: 568flask.abort(401) 569 570if resource.author != current_user and not current_user.admin: 571flask.abort(403) 572 573return flask.render_template("picture-annotation.html", resource=resource, 574file_extension=mimetypes.guess_extension(resource.file_format)) 575 576 577@app.route("/picture/<int:id>/put-annotations-form") 578def put_annotations_form(id): 579resource = db.session.get(PictureResource, id) 580if resource is None: 581flask.abort(404) 582 583current_user = db.session.get(User, flask.session.get("username")) 584if current_user is None: 585flask.abort(401) 586 587if resource.author != current_user and not current_user.admin: 588flask.abort(403) 589 590return flask.render_template("put-annotations-form.html", resource=resource) 591 592 593@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 594def put_annotations_form_post(id): 595resource = db.session.get(PictureResource, id) 596if resource is None: 597flask.abort(404) 598 599current_user = db.session.get(User, flask.session.get("username")) 600if current_user is None: 601flask.abort(401) 602 603if resource.author != current_user and not current_user.admin: 604flask.abort(403) 605 606resource.put_annotations(json.loads(flask.request.form["annotations"])) 607 608db.session.commit() 609 610return flask.redirect("/picture/" + str(resource.id)) 611 612 613@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 614@app.route("/api/picture/<int:id>/put-annotations", methods=["POST"]) 615def save_annotations(id): 616resource = db.session.get(PictureResource, id) 617if resource is None: 618flask.abort(404) 619 620current_user = db.session.get(User, flask.session.get("username")) 621if resource.author != current_user and not current_user.admin: 622flask.abort(403) 623 624resource.put_annotations(flask.request.json) 625 626db.session.commit() 627 628response = flask.make_response() 629response.status_code = 204 630return response 631 632 633@app.route("/picture/<int:id>/get-annotations") 634@app.route("/api/picture/<int:id>/api/get-annotations") 635def get_annotations(id): 636resource = db.session.get(PictureResource, id) 637if resource is None: 638flask.abort(404) 639 640regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 641 642regions_json = [] 643 644for region in regions: 645regions_json.append({ 646"object": region.object_id, 647"type": region.json["type"], 648"shape": region.json["shape"], 649}) 650 651return flask.jsonify(regions_json) 652 653 654@app.route("/picture/<int:id>/delete") 655def delete_picture(id): 656resource = db.session.get(PictureResource, id) 657if resource is None: 658flask.abort(404) 659 660current_user = db.session.get(User, flask.session.get("username")) 661if current_user is None: 662flask.abort(401) 663 664if resource.author != current_user and not current_user.admin: 665flask.abort(403) 666 667PictureLicence.query.filter_by(resource=resource).delete() 668PictureRegion.query.filter_by(resource=resource).delete() 669PictureInGallery.query.filter_by(resource=resource).delete() 670PictureRating.query.filter_by(resource=resource).delete() 671if resource.replaces: 672resource.replaces.replaced_by = None 673if resource.replaced_by: 674resource.replaced_by.replaces = None 675resource.copied_from = None 676for copy in resource.copies: 677copy.copied_from = None 678db.session.delete(resource) 679db.session.commit() 680 681return flask.redirect("/") 682 683 684@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 685def mark_picture_replacement(id): 686resource = db.session.get(PictureResource, id) 687if resource is None: 688flask.abort(404) 689 690current_user = db.session.get(User, flask.session.get("username")) 691if current_user is None: 692flask.abort(401) 693 694if resource.copied_from.author != current_user and not current_user.admin: 695flask.abort(403) 696 697resource.copied_from.replaced_by = resource 698resource.replaces = resource.copied_from 699 700db.session.commit() 701 702return flask.redirect("/picture/" + str(resource.copied_from.id)) 703 704 705@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 706def remove_picture_replacement(id): 707resource = db.session.get(PictureResource, id) 708if resource is None: 709flask.abort(404) 710 711current_user = db.session.get(User, flask.session.get("username")) 712if current_user is None: 713flask.abort(401) 714 715if resource.author != current_user and not current_user.admin: 716flask.abort(403) 717 718resource.replaced_by.replaces = None 719resource.replaced_by = None 720 721db.session.commit() 722 723return flask.redirect("/picture/" + str(resource.id)) 724 725 726@app.route("/picture/<int:id>/edit-metadata") 727def edit_picture(id): 728resource = db.session.get(PictureResource, id) 729if resource is None: 730flask.abort(404) 731 732current_user = db.session.get(User, flask.session.get("username")) 733if current_user is None: 734flask.abort(401) 735 736if resource.author != current_user and not current_user.admin: 737flask.abort(403) 738 739licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 740Licence.title).all() 741 742types = PictureNature.query.all() 743 744return flask.render_template("edit-picture.html", resource=resource, licences=licences, 745types=types, 746PictureLicence=PictureLicence) 747 748 749@app.route("/picture/<int:id>/rate", methods=["POST"]) 750def rate_picture(id): 751resource = db.session.get(PictureResource, id) 752if resource is None: 753flask.abort(404) 754 755current_user = db.session.get(User, flask.session.get("username")) 756if current_user is None: 757flask.abort(401) 758 759rating = int(flask.request.form.get("rating")) 760 761if not rating: 762# Delete the existing rating 763if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 764db.session.delete(PictureRating.query.filter_by(resource=resource, 765user=current_user).first()) 766db.session.commit() 767 768return flask.redirect("/picture/" + str(resource.id)) 769 770if not 1 <= rating <= 5: 771flask.flash("Invalid rating") 772return flask.redirect("/picture/" + str(resource.id)) 773 774if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 775PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 776else: 777# Create a new rating 778db.session.add(PictureRating(resource, current_user, rating)) 779 780db.session.commit() 781 782return flask.redirect("/picture/" + str(resource.id)) 783 784 785@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 786def edit_picture_post(id): 787resource = db.session.get(PictureResource, id) 788if resource is None: 789flask.abort(404) 790 791current_user = db.session.get(User, flask.session.get("username")) 792if current_user is None: 793flask.abort(401) 794 795if resource.author != current_user and not current_user.admin: 796flask.abort(403) 797 798title = flask.request.form["title"] 799description = flask.request.form["description"] 800origin_url = flask.request.form["origin_url"] 801licence_ids = flask.request.form.getlist("licence") 802nature_id = flask.request.form["nature"] 803 804if not title: 805flask.flash("Enter a title") 806return flask.redirect(flask.request.url) 807 808if not description: 809description = "" 810 811if not nature_id: 812flask.flash("Select a picture type") 813return flask.redirect(flask.request.url) 814 815if not licence_ids: 816flask.flash("Select licences") 817return flask.redirect(flask.request.url) 818 819licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 820if not any(licence.free for licence in licences): 821flask.flash("Select at least one free licence") 822return flask.redirect(flask.request.url) 823 824resource.title = title 825resource.description = description 826resource.origin_url = origin_url 827for licence_id in licence_ids: 828joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 829db.session.add(joiner) 830resource.nature = db.session.get(PictureNature, nature_id) 831 832db.session.commit() 833 834return flask.redirect("/picture/" + str(resource.id)) 835 836 837@app.route("/picture/<int:id>/copy") 838def copy_picture(id): 839resource = db.session.get(PictureResource, id) 840if resource is None: 841flask.abort(404) 842 843current_user = db.session.get(User, flask.session.get("username")) 844if current_user is None: 845flask.abort(401) 846 847new_resource = PictureResource(resource.title, current_user, resource.description, 848resource.origin_url, 849[licence.licence_id for licence in resource.licences], 850resource.file_format, 851resource.nature) 852 853for region in resource.regions: 854db.session.add(PictureRegion(region.json, new_resource, region.object)) 855 856db.session.commit() 857 858# Create a hard link for the new picture 859old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 860new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 861os.link(old_path, new_path) 862 863new_resource.width = resource.width 864new_resource.height = resource.height 865new_resource.copied_from = resource 866 867db.session.commit() 868 869return flask.redirect("/picture/" + str(new_resource.id)) 870 871 872@app.route("/gallery/<int:id>/") 873def gallery(id): 874gallery = db.session.get(Gallery, id) 875if gallery is None: 876flask.abort(404) 877 878current_user = db.session.get(User, flask.session.get("username")) 879 880have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 881 882return flask.render_template("gallery.html", gallery=gallery, 883have_permission=have_permission) 884 885 886@app.route("/create-gallery") 887def create_gallery(): 888if "username" not in flask.session: 889flask.flash("Log in to create galleries.") 890return flask.redirect("/accounts") 891 892return flask.render_template("create-gallery.html") 893 894 895@app.route("/create-gallery", methods=["POST"]) 896def create_gallery_post(): 897if not flask.session.get("username"): 898flask.abort(401) 899 900if not flask.request.form.get("title"): 901flask.flash("Enter a title") 902return flask.redirect(flask.request.url) 903 904description = flask.request.form.get("description", "") 905 906gallery = Gallery(flask.request.form["title"], description, 907db.session.get(User, flask.session["username"])) 908db.session.add(gallery) 909db.session.commit() 910 911return flask.redirect("/gallery/" + str(gallery.id)) 912 913 914@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 915def gallery_add_picture(id): 916gallery = db.session.get(Gallery, id) 917if gallery is None: 918flask.abort(404) 919 920if "username" not in flask.session: 921flask.abort(401) 922 923if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 924flask.abort(403) 925 926picture_id = flask.request.form.get("picture_id") 927if "/" in picture_id: # also allow full URLs 928picture_id = picture_id.rstrip("/").rpartition("/")[1] 929if not picture_id: 930flask.flash("Select a picture") 931return flask.redirect("/gallery/" + str(gallery.id)) 932picture_id = int(picture_id) 933 934picture = db.session.get(PictureResource, picture_id) 935if picture is None: 936flask.flash("Invalid picture") 937return flask.redirect("/gallery/" + str(gallery.id)) 938 939if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 940flask.flash("This picture is already in the gallery") 941return flask.redirect("/gallery/" + str(gallery.id)) 942 943db.session.add(PictureInGallery(picture, gallery)) 944 945db.session.commit() 946 947return flask.redirect("/gallery/" + str(gallery.id)) 948 949 950@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 951def gallery_remove_picture(id): 952gallery = db.session.get(Gallery, id) 953if gallery is None: 954flask.abort(404) 955 956if "username" not in flask.session: 957flask.abort(401) 958 959current_user = db.session.get(User, flask.session.get("username")) 960 961if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 962flask.abort(403) 963 964picture_id = int(flask.request.form.get("picture_id")) 965 966picture = db.session.get(PictureResource, picture_id) 967if picture is None: 968flask.flash("Invalid picture") 969return flask.redirect("/gallery/" + str(gallery.id)) 970 971picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 972gallery=gallery).first() 973if picture_in_gallery is None: 974flask.flash("This picture isn't in the gallery") 975return flask.redirect("/gallery/" + str(gallery.id)) 976 977db.session.delete(picture_in_gallery) 978 979db.session.commit() 980 981return flask.redirect("/gallery/" + str(gallery.id)) 982 983 984@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 985def gallery_add_from_query(id): 986gallery = db.session.get(Gallery, id) 987if gallery is None: 988flask.abort(404) 989 990if "username" not in flask.session: 991flask.abort(401) 992 993if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 994flask.abort(403) 995 996query_yaml = flask.request.form.get("query", "") 997 998yaml_parser = yaml.YAML() 999query_data = yaml_parser.load(query_yaml) or {} 1000query = get_picture_query(query_data) 1001 1002pictures = query.all() 1003 1004count = 0 1005 1006for picture in pictures: 1007if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1008db.session.add(PictureInGallery(picture, gallery)) 1009count += 1 1010 1011db.session.commit() 1012 1013flask.flash(f"Added {count} pictures to the gallery") 1014 1015return flask.redirect("/gallery/" + str(gallery.id)) 1016 1017 1018@app.route("/gallery/<int:id>/users") 1019def gallery_users(id): 1020gallery = db.session.get(Gallery, id) 1021if gallery is None: 1022flask.abort(404) 1023 1024current_user = db.session.get(User, flask.session.get("username")) 1025have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1026 1027return flask.render_template("gallery-users.html", gallery=gallery, 1028have_permission=have_permission) 1029 1030 1031@app.route("/gallery/<int:id>/edit") 1032def edit_gallery(id): 1033gallery = db.session.get(Gallery, id) 1034if gallery is None: 1035flask.abort(404) 1036 1037current_user = db.session.get(User, flask.session.get("username")) 1038if current_user is None: 1039flask.abort(401) 1040 1041if current_user != gallery.owner and not current_user.admin: 1042flask.abort(403) 1043 1044return flask.render_template("edit-gallery.html", gallery=gallery) 1045 1046 1047@app.route("/gallery/<int:id>/edit", methods=["POST"]) 1048def edit_gallery_post(id): 1049gallery = db.session.get(Gallery, id) 1050if gallery is None: 1051flask.abort(404) 1052 1053current_user = db.session.get(User, flask.session.get("username")) 1054if current_user is None: 1055flask.abort(401) 1056 1057if current_user != gallery.owner and not current_user.admin: 1058flask.abort(403) 1059 1060title = flask.request.form["title"] 1061description = flask.request.form.get("description") 1062 1063if not title: 1064flask.flash("Enter a title") 1065return flask.redirect(flask.request.url) 1066 1067if not description: 1068description = "" 1069 1070gallery.title = title 1071gallery.description = description 1072 1073db.session.commit() 1074 1075return flask.redirect("/gallery/" + str(gallery.id)) 1076 1077 1078@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1079def gallery_add_user(id): 1080gallery = db.session.get(Gallery, id) 1081if gallery is None: 1082flask.abort(404) 1083 1084current_user = db.session.get(User, flask.session.get("username")) 1085if current_user is None: 1086flask.abort(401) 1087 1088if current_user != gallery.owner and not current_user.admin: 1089flask.abort(403) 1090 1091username = flask.request.form.get("username") 1092if username == gallery.owner_name: 1093flask.flash("The owner is already in the gallery") 1094return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1095 1096user = db.session.get(User, username) 1097if user is None: 1098flask.flash("User not found") 1099return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1100 1101if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1102flask.flash("User is already in the gallery") 1103return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1104 1105db.session.add(UserInGallery(user, gallery)) 1106 1107db.session.commit() 1108 1109return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1110 1111 1112@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1113def gallery_remove_user(id): 1114gallery = db.session.get(Gallery, id) 1115if gallery is None: 1116flask.abort(404) 1117 1118current_user = db.session.get(User, flask.session.get("username")) 1119if current_user is None: 1120flask.abort(401) 1121 1122if current_user != gallery.owner and not current_user.admin: 1123flask.abort(403) 1124 1125username = flask.request.form.get("username") 1126user = db.session.get(User, username) 1127if user is None: 1128flask.flash("User not found") 1129return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1130 1131user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1132if user_in_gallery is None: 1133flask.flash("User is not in the gallery") 1134return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1135 1136db.session.delete(user_in_gallery) 1137 1138db.session.commit() 1139 1140return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1141 1142 1143class APIError(Exception): 1144def __init__(self, status_code, message): 1145self.status_code = status_code 1146self.message = message 1147 1148 1149def get_picture_query(query_data): 1150query = db.session.query(PictureResource) 1151 1152descendants_cte = ( 1153db.select(PictureObject.id) 1154.where(PictureObject.id == PictureRegion.object_id) 1155.cte(name="descendants_cte", recursive=True) 1156) 1157 1158descendants_cte = descendants_cte.union_all( 1159db.select(PictureObjectInheritance.child_id) 1160.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 1161) 1162 1163requirement_conditions = { 1164# Has an object with the ID in the given list 1165"has_object": lambda value: PictureResource.regions.any( 1166PictureRegion.object_id.in_(value)), 1167# Has an object with the ID in the given list, or a subtype of it 1168"has": lambda value: PictureResource.regions.any( 1169PictureRegion.object_id.in_( 1170db.select(descendants_cte.c.id) 1171) 1172), 1173 1174"nature": lambda value: PictureResource.nature_id.in_(value), 1175"licence": lambda value: PictureResource.licences.any( 1176PictureLicence.licence_id.in_(value)), 1177"author": lambda value: PictureResource.author_name.in_(value), 1178"title": lambda value: PictureResource.title.ilike(value), 1179"description": lambda value: PictureResource.description.ilike(value), 1180"origin_url": lambda value: db.func.lower(db.func.substr( 1181PictureResource.origin_url, 1182db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1183)).in_(value), 1184"above_width": lambda value: PictureResource.width >= value, 1185"below_width": lambda value: PictureResource.width <= value, 1186"above_height": lambda value: PictureResource.height >= value, 1187"below_height": lambda value: PictureResource.height <= value, 1188"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1189value), 1190"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1191value), 1192"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1193"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value, 1194"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value, 1195"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1196"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1197"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1198"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1199"copied_from": lambda value: PictureResource.copied_from_id.in_(value), 1200} 1201 1202if "want" in query_data: 1203for i in query_data["want"]: 1204if len(i) != 1: 1205raise APIError(400, "Each requirement must have exactly one key") 1206requirement, value = list(i.items())[0] 1207if requirement not in requirement_conditions: 1208raise APIError(400, f"Unknown requirement type: {requirement}") 1209 1210condition = requirement_conditions[requirement] 1211query = query.filter(condition(value)) 1212if "exclude" in query_data: 1213for i in query_data["exclude"]: 1214if len(i) != 1: 1215raise APIError(400, "Each exclusion must have exactly one key") 1216requirement, value = list(i.items())[0] 1217if requirement not in requirement_conditions: 1218raise APIError(400, f"Unknown requirement type: {requirement}") 1219 1220condition = requirement_conditions[requirement] 1221query = query.filter(~condition(value)) 1222if not query_data.get("include_obsolete", False): 1223query = query.filter(PictureResource.replaced_by_id.is_(None)) 1224 1225return query 1226 1227 1228@app.route("/query-pictures") 1229def graphical_query_pictures(): 1230return flask.render_template("graphical-query-pictures.html") 1231 1232 1233@app.route("/query-pictures-results") 1234def graphical_query_pictures_results(): 1235query_yaml = flask.request.args.get("query", "") 1236yaml_parser = yaml.YAML() 1237query_data = yaml_parser.load(query_yaml) or {} 1238try: 1239query = get_picture_query(query_data) 1240except APIError as e: 1241flask.abort(e.status_code) 1242 1243page = int(flask.request.args.get("page", 1)) 1244per_page = int(flask.request.args.get("per_page", 16)) 1245 1246resources = query.paginate(page=page, per_page=per_page) 1247 1248return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1249query=query_yaml, 1250page_number=page, page_length=per_page, 1251num_pages=resources.pages, 1252prev_page=resources.prev_num, next_page=resources.next_num) 1253 1254 1255@app.route("/raw/picture/<int:id>") 1256def raw_picture(id): 1257resource = db.session.get(PictureResource, id) 1258if resource is None: 1259flask.abort(404) 1260 1261response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1262str(resource.id)) 1263response.mimetype = resource.file_format 1264 1265return response 1266 1267 1268@app.route("/object/") 1269def graphical_object_types(): 1270return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1271 1272 1273@app.route("/api/object-types") 1274def object_types(): 1275objects = db.session.query(PictureObject).all() 1276return flask.jsonify({object.id: object.description for object in objects}) 1277 1278 1279@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1280def query_pictures(): 1281offset = int(flask.request.args.get("offset", 0)) 1282limit = int(flask.request.args.get("limit", 16)) 1283ordering = flask.request.args.get("ordering", "date-desc") 1284 1285yaml_parser = yaml.YAML() 1286query_data = yaml_parser.load(flask.request.data) or {} 1287try: 1288query = get_picture_query(query_data) 1289except APIError as e: 1290return flask.jsonify({"error": e.message}), e.status_code 1291 1292rating_count_subquery = db.select(db.func.count(PictureRating.id)).where( 1293PictureRating.resource_id == PictureResource.id).scalar_subquery() 1294region_count_subquery = db.select(db.func.count(PictureRegion.id)).where( 1295PictureRegion.resource_id == PictureResource.id).scalar_subquery() 1296rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where( 1297PictureRating.resource_id == PictureResource.id).scalar_subquery() 1298 1299match ordering: 1300case "date-desc": 1301query = query.order_by(PictureResource.timestamp.desc()) 1302case "date-asc": 1303query = query.order_by(PictureResource.timestamp.asc()) 1304case "title-asc": 1305query = query.order_by(PictureResource.title.asc()) 1306case "title-desc": 1307query = query.order_by(PictureResource.title.desc()) 1308case "random": 1309query = query.order_by(db.func.random()) 1310case "number-regions-desc": 1311query = query.order_by(region_count_subquery.desc()) 1312case "number-regions-asc": 1313query = query.order_by(region_count_subquery.asc()) 1314case "rating-desc": 1315query = query.order_by(rating_subquery.desc()) 1316case "rating-asc": 1317query = query.order_by(rating_subquery.asc()) 1318case "number-ratings-desc": 1319query = query.order_by(rating_count_subquery.desc()) 1320case "number-ratings-asc": 1321query = query.order_by(rating_count_subquery.asc()) 1322 1323query = query.offset(offset).limit(limit) 1324resources = query.all() 1325 1326json_response = { 1327"date_generated": datetime.utcnow().timestamp(), 1328"resources": [], 1329"offset": offset, 1330"limit": limit, 1331} 1332 1333json_resources = json_response["resources"] 1334 1335for resource in resources: 1336json_resource = { 1337"id": resource.id, 1338"title": resource.title, 1339"description": resource.description, 1340"timestamp": resource.timestamp.timestamp(), 1341"origin_url": resource.origin_url, 1342"author": resource.author_name, 1343"file_format": resource.file_format, 1344"width": resource.width, 1345"height": resource.height, 1346"nature": resource.nature_id, 1347"licences": [licence.licence_id for licence in resource.licences], 1348"replaces": resource.replaces_id, 1349"replaced_by": resource.replaced_by_id, 1350"regions": [], 1351"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1352} 1353for region in resource.regions: 1354json_resource["regions"].append({ 1355"object": region.object_id, 1356"type": region.json["type"], 1357"shape": region.json["shape"], 1358}) 1359 1360json_resources.append(json_resource) 1361 1362return flask.jsonify(json_response) 1363 1364 1365@app.route("/api/picture/<int:id>/") 1366def api_picture(id): 1367resource = db.session.get(PictureResource, id) 1368if resource is None: 1369flask.abort(404) 1370 1371json_resource = { 1372"id": resource.id, 1373"title": resource.title, 1374"description": resource.description, 1375"timestamp": resource.timestamp.timestamp(), 1376"origin_url": resource.origin_url, 1377"author": resource.author_name, 1378"file_format": resource.file_format, 1379"width": resource.width, 1380"height": resource.height, 1381"nature": resource.nature_id, 1382"licences": [licence.licence_id for licence in resource.licences], 1383"replaces": resource.replaces_id, 1384"replaced_by": resource.replaced_by_id, 1385"regions": [], 1386"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1387"rating_average": resource.average_rating, 1388"rating_count": resource.rating_totals, 1389} 1390for region in resource.regions: 1391json_resource["regions"].append({ 1392"object": region.object_id, 1393"type": region.json["type"], 1394"shape": region.json["shape"], 1395}) 1396 1397return flask.jsonify(json_resource) 1398 1399 1400@app.route("/api/licence/") 1401def api_licences(): 1402licences = db.session.query(Licence).all() 1403json_licences = { 1404licence.id: { 1405"title": licence.title, 1406"free": licence.free, 1407"pinned": licence.pinned, 1408} for licence in licences 1409} 1410 1411return flask.jsonify(json_licences) 1412 1413 1414@app.route("/api/licence/<id>/") 1415def api_licence(id): 1416licence = db.session.get(Licence, id) 1417if licence is None: 1418flask.abort(404) 1419 1420json_licence = { 1421"id": licence.id, 1422"title": licence.title, 1423"description": licence.description, 1424"info_url": licence.info_url, 1425"legalese_url": licence.url, 1426"free": licence.free, 1427"logo_url": licence.logo_url, 1428"pinned": licence.pinned, 1429} 1430 1431return flask.jsonify(json_licence) 1432 1433 1434@app.route("/api/nature/") 1435def api_natures(): 1436natures = db.session.query(PictureNature).all() 1437json_natures = { 1438nature.id: nature.description for nature in natures 1439} 1440 1441return flask.jsonify(json_natures) 1442 1443 1444@app.route("/api/user/") 1445def api_users(): 1446offset = int(flask.request.args.get("offset", 0)) 1447limit = int(flask.request.args.get("limit", 16)) 1448 1449users = db.session.query(User).offset(offset).limit(limit).all() 1450 1451json_users = { 1452user.username: { 1453"admin": user.admin, 1454} for user in users 1455} 1456 1457return flask.jsonify(json_users) 1458 1459 1460@app.route("/api/user/<username>/") 1461def api_user(username): 1462user = db.session.get(User, username) 1463if user is None: 1464flask.abort(404) 1465 1466json_user = { 1467"username": user.username, 1468"admin": user.admin, 1469"joined": user.joined_timestamp.timestamp(), 1470} 1471 1472return flask.jsonify(json_user) 1473 1474 1475@app.route("/api/login", methods=["POST"]) 1476def api_login(): 1477username = flask.request.json["username"] 1478password = flask.request.json["password"] 1479 1480user = db.session.get(User, username) 1481 1482if user is None: 1483return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1484 1485if not bcrypt.check_password_hash(user.password_hashed, password): 1486return flask.jsonify({"error": "Incorrect password"}), 401 1487 1488flask.session["username"] = username 1489 1490return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1491 1492 1493@app.route("/api/logout", methods=["POST"]) 1494def api_logout(): 1495flask.session.pop("username", None) 1496return flask.jsonify({"message": "You have been logged out."}) 1497 1498 1499@app.route("/api/upload", methods=["POST"]) 1500def api_upload(): 1501if "username" not in flask.session: 1502return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1503 1504json_ = json.loads(flask.request.form["json"]) 1505title = json_["title"] 1506description = json_.get("description", "") 1507origin_url = json_.get("origin_url", "") 1508author = db.session.get(User, flask.session["username"]) 1509licence_ids = json_["licence"] 1510nature_id = json_["nature"] 1511file = flask.request.files["file"] 1512 1513if not file or not file.filename: 1514return flask.jsonify({"error": "An image file must be uploaded"}), 400 1515 1516if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1517return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1518 1519if not title: 1520return flask.jsonify({"error": "Give a title"}), 400 1521 1522if not description: 1523description = "" 1524 1525if not nature_id: 1526return flask.jsonify({"error": "Give a picture type"}), 400 1527 1528if not licence_ids: 1529return flask.jsonify({"error": "Give licences"}), 400 1530 1531licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1532if not any(licence.free for licence in licences): 1533return flask.jsonify({"error": "Use at least one free licence"}), 400 1534 1535resource = PictureResource(title, author, description, origin_url, licence_ids, 1536file.mimetype, 1537db.session.get(PictureNature, nature_id)) 1538db.session.add(resource) 1539db.session.commit() 1540file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1541pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1542resource.width, resource.height = pil_image.size 1543db.session.commit() 1544 1545if json_.get("annotations"): 1546try: 1547resource.put_annotations(json_["annotations"]) 1548db.session.commit() 1549except json.JSONDecodeError: 1550return flask.jsonify({"error": "Invalid annotations"}), 400 1551 1552return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id}) 1553 1554 1555@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1556def api_update_picture(id): 1557resource = db.session.get(PictureResource, id) 1558if resource is None: 1559return flask.jsonify({"error": "Picture not found"}), 404 1560current_user = db.session.get(User, flask.session.get("username")) 1561if current_user is None: 1562return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1563if resource.author != current_user and not current_user.admin: 1564return flask.jsonify({"error": "You are not the author of this picture"}), 403 1565 1566title = flask.request.json.get("title", resource.title) 1567description = flask.request.json.get("description", resource.description) 1568origin_url = flask.request.json.get("origin_url", resource.origin_url) 1569licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1570nature_id = flask.request.json.get("nature", resource.nature_id) 1571 1572if not title: 1573return flask.jsonify({"error": "Give a title"}), 400 1574 1575if not description: 1576description = "" 1577 1578if not nature_id: 1579return flask.jsonify({"error": "Give a picture type"}), 400 1580 1581if not licence_ids: 1582return flask.jsonify({"error": "Give licences"}), 400 1583 1584licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1585 1586if not any(licence.free for licence in licences): 1587return flask.jsonify({"error": "Use at least one free licence"}), 400 1588 1589resource.title = title 1590resource.description = description 1591resource.origin_url = origin_url 1592resource.licences = licences 1593resource.nature = db.session.get(PictureNature, nature_id) 1594 1595db.session.commit() 1596 1597return flask.jsonify({"message": "Picture updated successfully"}) 1598 1599 1600@app.route("/api/picture/<int:id>/rate", methods=["POST"]) 1601def api_rate_picture(id): 1602resource = db.session.get(PictureResource, id) 1603if resource is None: 1604flask.abort(404) 1605 1606current_user = db.session.get(User, flask.session.get("username")) 1607if current_user is None: 1608flask.abort(401) 1609 1610rating = int(flask.request.json.get("rating", 0)) 1611 1612if not rating: 1613# Delete the existing rating 1614if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1615db.session.delete(PictureRating.query.filter_by(resource=resource, 1616user=current_user).first()) 1617db.session.commit() 1618 1619return flask.jsonify({"message": "Existing rating removed"}) 1620 1621if not 1 <= rating <= 5: 1622flask.flash("Invalid rating") 1623return flask.jsonify({"error": "Invalid rating"}), 400 1624 1625if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1626PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 1627else: 1628# Create a new rating 1629db.session.add(PictureRating(resource, current_user, rating)) 1630 1631db.session.commit() 1632 1633return flask.jsonify({"message": "Rating saved"}) 1634 1635 1636@app.route("/api/gallery/<int:id>/") 1637def api_gallery(id): 1638gallery = db.session.get(Gallery, id) 1639if gallery is None: 1640flask.abort(404) 1641 1642json_gallery = { 1643"id": gallery.id, 1644"title": gallery.title, 1645"description": gallery.description, 1646"owner": gallery.owner_name, 1647"users": [user.username for user in gallery.users], 1648} 1649 1650return flask.jsonify(json_gallery) 1651 1652 1653@app.route("/api/gallery/<int:id>/edit", methods=["POST"]) 1654def api_edit_gallery(id): 1655gallery = db.session.get(Gallery, id) 1656if gallery is None: 1657flask.abort(404) 1658 1659current_user = db.session.get(User, flask.session.get("username")) 1660if current_user is None: 1661flask.abort(401) 1662 1663if current_user != gallery.owner and not current_user.admin: 1664flask.abort(403) 1665 1666title = flask.request.json.get("title", gallery.title) 1667description = flask.request.json.get("description", gallery.description) 1668 1669if not title: 1670return flask.jsonify({"error": "Give a title"}), 400 1671 1672if not description: 1673description = "" 1674 1675gallery.title = title 1676gallery.description = description 1677 1678db.session.commit() 1679 1680return flask.jsonify({"message": "Gallery updated successfully"}) 1681 1682 1683@app.route("/api/new-gallery", methods=["POST"]) 1684def api_new_gallery(): 1685if "username" not in flask.session: 1686return flask.jsonify({"error": "You must be logged in to create galleries"}), 401 1687 1688title = flask.request.json.get("title") 1689description = flask.request.json.get("description", "") 1690 1691if not title: 1692return flask.jsonify({"error": "Give a title"}), 400 1693 1694gallery = Gallery(title, description, db.session.get(User, flask.session["username"])) 1695db.session.add(gallery) 1696db.session.commit() 1697 1698return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id}) 1699 1700 1701@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"]) 1702def api_gallery_add_picture(id): 1703gallery = db.session.get(Gallery, id) 1704if gallery is None: 1705flask.abort(404) 1706 1707if "username" not in flask.session: 1708return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401 1709 1710current_user = db.session.get(User, flask.session.get("username")) 1711 1712if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1713return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403 1714 1715picture_id = flask.request.json.get("picture_id") 1716 1717try: 1718picture_id = int(picture_id) 1719except ValueError: 1720return flask.jsonify({"error": "Invalid picture ID"}), 400 1721 1722picture = db.session.get(PictureResource, picture_id) 1723if picture is None: 1724return flask.jsonify({"error": "The picture doesn't exist"}), 404 1725 1726if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1727return flask.jsonify({"error": "This picture is already in the gallery"}), 400 1728 1729db.session.add(PictureInGallery(picture, gallery)) 1730 1731db.session.commit() 1732 1733return flask.jsonify({"message": "Picture added to gallery"}) 1734 1735 1736@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"]) 1737def api_gallery_remove_picture(id): 1738gallery = db.session.get(Gallery, id) 1739if gallery is None: 1740flask.abort(404) 1741 1742if "username" not in flask.session: 1743return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401 1744 1745current_user = db.session.get(User, flask.session.get("username")) 1746 1747if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1748return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403 1749 1750picture_id = flask.request.json.get("picture_id") 1751 1752try: 1753picture_id = int(picture_id) 1754except ValueError: 1755return flask.jsonify({"error": "Invalid picture ID"}), 400 1756 1757picture = db.session.get(PictureResource, picture_id) 1758if picture is None: 1759return flask.jsonify({"error": "The picture doesn't exist"}), 404 1760 1761picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 1762if picture_in_gallery is None: 1763return flask.jsonify({"error": "This picture isn't in the gallery"}), 400 1764 1765db.session.delete(picture_in_gallery) 1766 1767db.session.commit() 1768 1769return flask.jsonify({"message": "Picture removed from gallery"}) 1770 1771 1772@app.route("/api/gallery/<int:id>/users/add", methods=["POST"]) 1773def api_gallery_add_user(id): 1774gallery = db.session.get(Gallery, id) 1775if gallery is None: 1776flask.abort(404) 1777 1778current_user = db.session.get(User, flask.session.get("username")) 1779if current_user is None: 1780flask.abort(401) 1781 1782if current_user != gallery.owner and not current_user.admin: 1783flask.abort(403) 1784 1785username = flask.request.json.get("username") 1786if username == gallery.owner_name: 1787return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400 1788 1789user = db.session.get(User, username) 1790if user is None: 1791return flask.jsonify({"error": "User not found"}), 404 1792 1793if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1794return flask.jsonify({"error": "User is already in the gallery"}), 400 1795 1796db.session.add(UserInGallery(user, gallery)) 1797 1798db.session.commit() 1799 1800return flask.jsonify({"message": "User added to gallery"}) 1801 1802 1803@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"]) 1804def api_gallery_remove_user(id): 1805gallery = db.session.get(Gallery, id) 1806if gallery is None: 1807flask.abort(404) 1808 1809current_user = db.session.get(User, flask.session.get("username")) 1810if current_user is None: 1811flask.abort(401) 1812 1813if current_user != gallery.owner and not current_user.admin: 1814flask.abort(403) 1815 1816username = flask.request.json.get("username") 1817user = db.session.get(User, username) 1818if user is None: 1819return flask.jsonify({"error": "User not found"}), 404 1820 1821user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1822if user_in_gallery is None: 1823return flask.jsonify({"error": "User is not in the gallery"}), 400 1824 1825db.session.delete(user_in_gallery) 1826 1827db.session.commit() 1828 1829return flask.jsonify({"message": "User removed from gallery"}) 1830 1831