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