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