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