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