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
print(file_path)
948
file.save(file_path)
949
pil_image = Image.open(file_path)
950
resource.width, resource.height = pil_image.size
951
pil_image = make_thumbnail(pil_image)
952
pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS)
953
954
return flask.redirect("/picture/" + str(resource.id))
955
956
957
@app.route("/gallery/<int:id>/")
958
def gallery(id):
959
gallery = db.session.get(Gallery, id)
960
if gallery is None:
961
flask.abort(404)
962
963
current_user = db.session.get(User, flask.session.get("username"))
964
965
have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first())
966
have_extended_permission = current_user and (current_user == gallery.owner or current_user.admin)
967
968
return flask.render_template("gallery.html", gallery=gallery,
969
have_permission=have_permission,
970
have_extended_permission=have_extended_permission)
971
972
973
@app.route("/create-gallery")
974
def create_gallery():
975
if "username" not in flask.session:
976
flask.flash("Log in to create galleries.")
977
return flask.redirect("/accounts")
978
979
return flask.render_template("create-gallery.html")
980
981
982
@app.route("/create-gallery", methods=["POST"])
983
def create_gallery_post():
984
if not flask.session.get("username"):
985
flask.abort(401)
986
987
if not flask.request.form.get("title"):
988
flask.flash("Enter a title")
989
return flask.redirect(flask.request.url)
990
991
description = flask.request.form.get("description", "")
992
993
gallery = Gallery(flask.request.form["title"], description,
994
db.session.get(User, flask.session["username"]))
995
db.session.add(gallery)
996
db.session.commit()
997
998
return flask.redirect("/gallery/" + str(gallery.id))
999
1000
1001
@app.route("/gallery/<int:id>/add-picture", methods=["POST"])
1002
def gallery_add_picture(id):
1003
gallery = db.session.get(Gallery, id)
1004
if gallery is None:
1005
flask.abort(404)
1006
1007
if "username" not in flask.session:
1008
flask.abort(401)
1009
1010
if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():
1011
flask.abort(403)
1012
1013
picture_id = flask.request.form.get("picture_id")
1014
if "/" in picture_id: # also allow full URLs
1015
picture_id = picture_id.rstrip("/").rpartition("/")[1]
1016
if not picture_id:
1017
flask.flash("Select a picture")
1018
return flask.redirect("/gallery/" + str(gallery.id))
1019
picture_id = int(picture_id)
1020
1021
picture = db.session.get(PictureResource, picture_id)
1022
if picture is None:
1023
flask.flash("Invalid picture")
1024
return flask.redirect("/gallery/" + str(gallery.id))
1025
1026
if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first():
1027
flask.flash("This picture is already in the gallery")
1028
return flask.redirect("/gallery/" + str(gallery.id))
1029
1030
db.session.add(PictureInGallery(picture, gallery))
1031
1032
db.session.commit()
1033
1034
return flask.redirect("/gallery/" + str(gallery.id))
1035
1036
1037
@app.route("/gallery/<int:id>/remove-picture", methods=["POST"])
1038
def gallery_remove_picture(id):
1039
gallery = db.session.get(Gallery, id)
1040
if gallery is None:
1041
flask.abort(404)
1042
1043
if "username" not in flask.session:
1044
flask.abort(401)
1045
1046
current_user = db.session.get(User, flask.session.get("username"))
1047
1048
if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():
1049
flask.abort(403)
1050
1051
picture_id = int(flask.request.form.get("picture_id"))
1052
1053
picture = db.session.get(PictureResource, picture_id)
1054
if picture is None:
1055
flask.flash("Invalid picture")
1056
return flask.redirect("/gallery/" + str(gallery.id))
1057
1058
picture_in_gallery = PictureInGallery.query.filter_by(resource=picture,
1059
gallery=gallery).first()
1060
if picture_in_gallery is None:
1061
flask.flash("This picture isn't in the gallery")
1062
return flask.redirect("/gallery/" + str(gallery.id))
1063
1064
db.session.delete(picture_in_gallery)
1065
1066
db.session.commit()
1067
1068
return flask.redirect("/gallery/" + str(gallery.id))
1069
1070
1071
@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"])
1072
def gallery_add_from_query(id):
1073
gallery = db.session.get(Gallery, id)
1074
if gallery is None:
1075
flask.abort(404)
1076
1077
if "username" not in flask.session:
1078
flask.abort(401)
1079
1080
if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():
1081
flask.abort(403)
1082
1083
query_yaml = flask.request.form.get("query", "")
1084
1085
yaml_parser = yaml.YAML()
1086
query_data = yaml_parser.load(query_yaml) or {}
1087
query = get_picture_query(query_data)
1088
1089
pictures = query.all()
1090
1091
count = 0
1092
1093
for picture in pictures:
1094
if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first():
1095
db.session.add(PictureInGallery(picture, gallery))
1096
count += 1
1097
1098
db.session.commit()
1099
1100
flask.flash(f"Added {count} pictures to the gallery")
1101
1102
return flask.redirect("/gallery/" + str(gallery.id))
1103
1104
1105
@app.route("/gallery/<int:id>/users")
1106
def gallery_users(id):
1107
gallery = db.session.get(Gallery, id)
1108
if gallery is None:
1109
flask.abort(404)
1110
1111
current_user = db.session.get(User, flask.session.get("username"))
1112
have_permission = current_user and (current_user == gallery.owner or current_user.admin)
1113
1114
return flask.render_template("gallery-users.html", gallery=gallery,
1115
have_permission=have_permission)
1116
1117
1118
@app.route("/gallery/<int:id>/edit")
1119
def edit_gallery(id):
1120
gallery = db.session.get(Gallery, id)
1121
if gallery is None:
1122
flask.abort(404)
1123
1124
current_user = db.session.get(User, flask.session.get("username"))
1125
if current_user is None:
1126
flask.abort(401)
1127
1128
if current_user != gallery.owner and not current_user.admin:
1129
flask.abort(403)
1130
1131
return flask.render_template("edit-gallery.html", gallery=gallery)
1132
1133
1134
@app.route("/gallery/<int:id>/edit", methods=["POST"])
1135
def edit_gallery_post(id):
1136
gallery = db.session.get(Gallery, id)
1137
if gallery is None:
1138
flask.abort(404)
1139
1140
current_user = db.session.get(User, flask.session.get("username"))
1141
if current_user is None:
1142
flask.abort(401)
1143
1144
if current_user != gallery.owner and not current_user.admin:
1145
flask.abort(403)
1146
1147
title = flask.request.form["title"]
1148
description = flask.request.form.get("description")
1149
1150
if not title:
1151
flask.flash("Enter a title")
1152
return flask.redirect(flask.request.url)
1153
1154
if not description:
1155
description = ""
1156
1157
gallery.title = title
1158
gallery.description = description
1159
1160
db.session.commit()
1161
1162
return flask.redirect("/gallery/" + str(gallery.id))
1163
1164
1165
@app.route("/gallery/<int:id>/delete")
1166
def delete_gallery(id):
1167
gallery = db.session.get(Gallery, id)
1168
if gallery is None:
1169
flask.abort(404)
1170
1171
current_user = db.session.get(User, flask.session.get("username"))
1172
if current_user is None:
1173
flask.abort(401)
1174
1175
if current_user != gallery.owner and not current_user.admin:
1176
flask.abort(403)
1177
1178
PictureInGallery.query.filter_by(gallery=gallery).delete()
1179
UserInGallery.query.filter_by(gallery=gallery).delete()
1180
db.session.delete(gallery)
1181
db.session.commit()
1182
1183
return flask.redirect("/")
1184
1185
1186
@app.route("/gallery/<int:id>/users/add", methods=["POST"])
1187
def gallery_add_user(id):
1188
gallery = db.session.get(Gallery, id)
1189
if gallery is None:
1190
flask.abort(404)
1191
1192
current_user = db.session.get(User, flask.session.get("username"))
1193
if current_user is None:
1194
flask.abort(401)
1195
1196
if current_user != gallery.owner and not current_user.admin:
1197
flask.abort(403)
1198
1199
username = flask.request.form.get("username")
1200
if username == gallery.owner_name:
1201
flask.flash("The owner is already in the gallery")
1202
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1203
1204
user = db.session.get(User, username)
1205
if user is None:
1206
flask.flash("User not found")
1207
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1208
1209
if UserInGallery.query.filter_by(user=user, gallery=gallery).first():
1210
flask.flash("User is already in the gallery")
1211
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1212
1213
db.session.add(UserInGallery(user, gallery))
1214
1215
db.session.commit()
1216
1217
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1218
1219
1220
@app.route("/gallery/<int:id>/users/remove", methods=["POST"])
1221
def gallery_remove_user(id):
1222
gallery = db.session.get(Gallery, id)
1223
if gallery is None:
1224
flask.abort(404)
1225
1226
current_user = db.session.get(User, flask.session.get("username"))
1227
if current_user is None:
1228
flask.abort(401)
1229
1230
if current_user != gallery.owner and not current_user.admin:
1231
flask.abort(403)
1232
1233
username = flask.request.form.get("username")
1234
user = db.session.get(User, username)
1235
if user is None:
1236
flask.flash("User not found")
1237
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1238
1239
user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first()
1240
if user_in_gallery is None:
1241
flask.flash("User is not in the gallery")
1242
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1243
1244
db.session.delete(user_in_gallery)
1245
1246
db.session.commit()
1247
1248
return flask.redirect("/gallery/" + str(gallery.id) + "/users")
1249
1250
1251
class APIError(Exception):
1252
def __init__(self, status_code, message):
1253
self.status_code = status_code
1254
self.message = message
1255
1256
1257
def get_picture_query(query_data):
1258
query = db.session.query(PictureResource)
1259
1260
def has_condition(id):
1261
descendants_cte = (
1262
db.select(PictureObject.id)
1263
.where(PictureObject.id == id)
1264
.cte(name=f"descendants_cte_{id}", recursive=True)
1265
)
1266
1267
descendants_cte = descendants_cte.union_all(
1268
db.select(PictureObjectInheritance.child_id)
1269
.where(PictureObjectInheritance.parent_id == descendants_cte.c.id)
1270
)
1271
1272
return PictureResource.regions.any(
1273
PictureRegion.object_id.in_(
1274
db.select(descendants_cte.c.id)
1275
)
1276
)
1277
1278
requirement_conditions = {
1279
# Has an object with the ID in the given list
1280
"has_object": lambda value: PictureResource.regions.any(
1281
PictureRegion.object_id.in_(value)),
1282
# Has an object with the ID in the given list, or a subtype of it
1283
"has": lambda value: db.or_(*[has_condition(id) for id in value]),
1284
"nature": lambda value: PictureResource.nature_id.in_(value),
1285
"licence": lambda value: PictureResource.licences.any(
1286
PictureLicence.licence_id.in_(value)),
1287
"author": lambda value: PictureResource.author_name.in_(value),
1288
"title": lambda value: PictureResource.title.ilike(value),
1289
"description": lambda value: PictureResource.description.ilike(value),
1290
"origin_url": lambda value: db.func.lower(db.func.substr(
1291
PictureResource.origin_url,
1292
db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4
1293
)).in_(value),
1294
"above_width": lambda value: PictureResource.width >= value,
1295
"below_width": lambda value: PictureResource.width <= value,
1296
"above_height": lambda value: PictureResource.height >= value,
1297
"below_height": lambda value: PictureResource.height <= value,
1298
"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp(
1299
value),
1300
"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp(
1301
value),
1302
"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)),
1303
"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value,
1304
"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value,
1305
"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value,
1306
"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value,
1307
"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value,
1308
"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value,
1309
"copied_from": lambda value: PictureResource.copied_from_id.in_(value),
1310
}
1311
1312
if "want" in query_data:
1313
for i in query_data["want"]:
1314
if len(i) != 1:
1315
raise APIError(400, "Each requirement must have exactly one key")
1316
requirement, value = list(i.items())[0]
1317
if requirement not in requirement_conditions:
1318
raise APIError(400, f"Unknown requirement type: {requirement}")
1319
1320
condition = requirement_conditions[requirement]
1321
query = query.filter(condition(value))
1322
if "exclude" in query_data:
1323
for i in query_data["exclude"]:
1324
if len(i) != 1:
1325
raise APIError(400, "Each exclusion must have exactly one key")
1326
requirement, value = list(i.items())[0]
1327
if requirement not in requirement_conditions:
1328
raise APIError(400, f"Unknown requirement type: {requirement}")
1329
1330
condition = requirement_conditions[requirement]
1331
query = query.filter(~condition(value))
1332
if not query_data.get("include_obsolete", False):
1333
query = query.filter(PictureResource.replaced_by_id.is_(None))
1334
1335
return query
1336
1337
1338
@app.route("/query-pictures")
1339
def graphical_query_pictures():
1340
return flask.render_template("graphical-query-pictures.html")
1341
1342
1343
@app.route("/query-pictures-results")
1344
def graphical_query_pictures_results():
1345
query_yaml = flask.request.args.get("query", "")
1346
yaml_parser = yaml.YAML()
1347
query_data = yaml_parser.load(query_yaml) or {}
1348
try:
1349
query = get_picture_query(query_data)
1350
except APIError as e:
1351
flask.abort(e.status_code)
1352
1353
page = int(flask.request.args.get("page", 1))
1354
per_page = int(flask.request.args.get("per_page", 16))
1355
1356
resources = query.paginate(page=page, per_page=per_page)
1357
1358
return flask.render_template("graphical-query-pictures-results.html", resources=resources,
1359
query=query_yaml,
1360
page_number=page, page_length=per_page,
1361
num_pages=resources.pages,
1362
prev_page=resources.prev_num, next_page=resources.next_num)
1363
1364
1365
@app.route("/raw/picture/<int:id>")
1366
def raw_picture(id):
1367
resource = db.session.get(PictureResource, id)
1368
if resource is None:
1369
flask.abort(404)
1370
1371
response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"),
1372
str(resource.id))
1373
response.mimetype = resource.file_format
1374
1375
return response
1376
1377
1378
@app.route("/raw/picture-thumbnail/<int:id>")
1379
def raw_picture_thumbnail(id):
1380
resource = db.session.get(PictureResource, id)
1381
if resource is None:
1382
flask.abort(404)
1383
1384
if not path.exists(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id))):
1385
pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id)))
1386
pil_image = make_thumbnail(pil_image)
1387
pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS)
1388
1389
response = flask.send_from_directory(path.join(config.DATA_PATH, "picture-thumbnails"),
1390
str(resource.id))
1391
response.mimetype = resource.file_format
1392
1393
return response
1394
1395
1396
@app.route("/object/")
1397
def graphical_object_types():
1398
return flask.render_template("object-types.html", objects=PictureObject.query.all())
1399
1400
1401
@app.route("/api/object-types")
1402
def object_types():
1403
objects = db.session.query(PictureObject).all()
1404
return flask.jsonify({object.id: object.description for object in objects})
1405
1406
1407
@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body
1408
def query_pictures():
1409
offset = int(flask.request.args.get("offset", 0))
1410
limit = int(flask.request.args.get("limit", 16))
1411
ordering = flask.request.args.get("ordering", "date-desc")
1412
1413
yaml_parser = yaml.YAML()
1414
query_data = yaml_parser.load(flask.request.data) or {}
1415
try:
1416
query = get_picture_query(query_data)
1417
except APIError as e:
1418
return flask.jsonify({"error": e.message}), e.status_code
1419
1420
rating_count_subquery = db.select(db.func.count(PictureRating.id)).where(
1421
PictureRating.resource_id == PictureResource.id).scalar_subquery()
1422
region_count_subquery = db.select(db.func.count(PictureRegion.id)).where(
1423
PictureRegion.resource_id == PictureResource.id).scalar_subquery()
1424
rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(
1425
PictureRating.resource_id == PictureResource.id).scalar_subquery()
1426
1427
match ordering:
1428
case "date-desc":
1429
query = query.order_by(PictureResource.timestamp.desc())
1430
case "date-asc":
1431
query = query.order_by(PictureResource.timestamp.asc())
1432
case "title-asc":
1433
query = query.order_by(PictureResource.title.asc())
1434
case "title-desc":
1435
query = query.order_by(PictureResource.title.desc())
1436
case "random":
1437
query = query.order_by(db.func.random())
1438
case "number-regions-desc":
1439
query = query.order_by(region_count_subquery.desc())
1440
case "number-regions-asc":
1441
query = query.order_by(region_count_subquery.asc())
1442
case "rating-desc":
1443
query = query.order_by(rating_subquery.desc())
1444
case "rating-asc":
1445
query = query.order_by(rating_subquery.asc())
1446
case "number-ratings-desc":
1447
query = query.order_by(rating_count_subquery.desc())
1448
case "number-ratings-asc":
1449
query = query.order_by(rating_count_subquery.asc())
1450
1451
query = query.offset(offset).limit(limit)
1452
resources = query.all()
1453
1454
json_response = {
1455
"date_generated": datetime.utcnow().timestamp(),
1456
"resources": [],
1457
"offset": offset,
1458
"limit": limit,
1459
}
1460
1461
json_resources = json_response["resources"]
1462
1463
for resource in resources:
1464
json_resource = {
1465
"id": resource.id,
1466
"title": resource.title,
1467
"description": resource.description,
1468
"timestamp": resource.timestamp.timestamp(),
1469
"origin_url": resource.origin_url,
1470
"author": resource.author_name,
1471
"file_format": resource.file_format,
1472
"width": resource.width,
1473
"height": resource.height,
1474
"nature": resource.nature_id,
1475
"licences": [licence.licence_id for licence in resource.licences],
1476
"replaces": resource.replaces_id,
1477
"replaced_by": resource.replaced_by_id,
1478
"regions": [],
1479
"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id),
1480
}
1481
for region in resource.regions:
1482
json_resource["regions"].append({
1483
"object": region.object_id,
1484
"type": region.json["type"],
1485
"shape": region.json["shape"],
1486
})
1487
1488
json_resources.append(json_resource)
1489
1490
return flask.jsonify(json_response)
1491
1492
1493
@app.route("/api/picture/<int:id>/")
1494
def api_picture(id):
1495
resource = db.session.get(PictureResource, id)
1496
if resource is None:
1497
flask.abort(404)
1498
1499
json_resource = {
1500
"id": resource.id,
1501
"title": resource.title,
1502
"description": resource.description,
1503
"timestamp": resource.timestamp.timestamp(),
1504
"origin_url": resource.origin_url,
1505
"author": resource.author_name,
1506
"file_format": resource.file_format,
1507
"width": resource.width,
1508
"height": resource.height,
1509
"nature": resource.nature_id,
1510
"licences": [licence.licence_id for licence in resource.licences],
1511
"replaces": resource.replaces_id,
1512
"replaced_by": resource.replaced_by_id,
1513
"regions": [],
1514
"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id),
1515
"rating_average": resource.average_rating,
1516
"rating_count": resource.rating_totals,
1517
}
1518
for region in resource.regions:
1519
json_resource["regions"].append({
1520
"object": region.object_id,
1521
"type": region.json["type"],
1522
"shape": region.json["shape"],
1523
})
1524
1525
return flask.jsonify(json_resource)
1526
1527
1528
@app.route("/api/licence/")
1529
def api_licences():
1530
licences = db.session.query(Licence).all()
1531
json_licences = {
1532
licence.id: {
1533
"title": licence.title,
1534
"free": licence.free,
1535
"pinned": licence.pinned,
1536
} for licence in licences
1537
}
1538
1539
return flask.jsonify(json_licences)
1540
1541
1542
@app.route("/api/licence/<id>/")
1543
def api_licence(id):
1544
licence = db.session.get(Licence, id)
1545
if licence is None:
1546
flask.abort(404)
1547
1548
json_licence = {
1549
"id": licence.id,
1550
"title": licence.title,
1551
"description": licence.description,
1552
"info_url": licence.info_url,
1553
"legalese_url": licence.url,
1554
"free": licence.free,
1555
"logo_url": licence.logo_url,
1556
"pinned": licence.pinned,
1557
}
1558
1559
return flask.jsonify(json_licence)
1560
1561
1562
@app.route("/api/nature/")
1563
def api_natures():
1564
natures = db.session.query(PictureNature).all()
1565
json_natures = {
1566
nature.id: nature.description for nature in natures
1567
}
1568
1569
return flask.jsonify(json_natures)
1570
1571
1572
@app.route("/api/user/")
1573
def api_users():
1574
offset = int(flask.request.args.get("offset", 0))
1575
limit = int(flask.request.args.get("limit", 16))
1576
1577
users = db.session.query(User).offset(offset).limit(limit).all()
1578
1579
json_users = {
1580
user.username: {
1581
"admin": user.admin,
1582
} for user in users
1583
}
1584
1585
return flask.jsonify(json_users)
1586
1587
1588
@app.route("/api/user/<username>/")
1589
def api_user(username):
1590
user = db.session.get(User, username)
1591
if user is None:
1592
flask.abort(404)
1593
1594
json_user = {
1595
"username": user.username,
1596
"admin": user.admin,
1597
"joined": user.joined_timestamp.timestamp(),
1598
}
1599
1600
return flask.jsonify(json_user)
1601
1602
1603
@app.route("/api/login", methods=["POST"])
1604
def api_login():
1605
username = flask.request.json["username"]
1606
password = flask.request.json["password"]
1607
1608
user = db.session.get(User, username)
1609
1610
if user is None:
1611
return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401
1612
1613
if not bcrypt.check_password_hash(user.password_hashed, password):
1614
return flask.jsonify({"error": "Incorrect password"}), 401
1615
1616
flask.session["username"] = username
1617
1618
return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."})
1619
1620
1621
@app.route("/api/logout", methods=["POST"])
1622
def api_logout():
1623
flask.session.pop("username", None)
1624
return flask.jsonify({"message": "You have been logged out."})
1625
1626
1627
@app.route("/api/upload", methods=["POST"])
1628
def api_upload():
1629
if "username" not in flask.session:
1630
return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401
1631
1632
json_ = json.loads(flask.request.form["json"])
1633
title = json_["title"]
1634
description = json_.get("description", "")
1635
origin_url = json_.get("origin_url", "")
1636
author = db.session.get(User, flask.session["username"])
1637
licence_ids = json_["licence"]
1638
nature_id = json_["nature"]
1639
file = flask.request.files["file"]
1640
1641
if not file or not file.filename:
1642
return flask.jsonify({"error": "An image file must be uploaded"}), 400
1643
1644
if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml":
1645
return flask.jsonify({"error": "Only bitmap images are supported"}), 400
1646
1647
if not title:
1648
return flask.jsonify({"error": "Give a title"}), 400
1649
1650
if not description:
1651
description = ""
1652
1653
if not nature_id:
1654
return flask.jsonify({"error": "Give a picture type"}), 400
1655
1656
if not licence_ids:
1657
return flask.jsonify({"error": "Give licences"}), 400
1658
1659
licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids]
1660
if not any(licence.free for licence in licences):
1661
return flask.jsonify({"error": "Use at least one free licence"}), 400
1662
1663
resource = PictureResource(title, author, description, origin_url, licence_ids,
1664
file.mimetype,
1665
db.session.get(PictureNature, nature_id))
1666
db.session.add(resource)
1667
db.session.commit()
1668
file.save(path.join(config.DATA_PATH, "pictures", str(resource.id)))
1669
pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id)))
1670
resource.width, resource.height = pil_image.size
1671
pil_image.thumbnail(config.THUMBNAIL_SIZE)
1672
pil_image = pil_image.convert("RGB")
1673
pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS)
1674
db.session.commit()
1675
1676
if json_.get("annotations"):
1677
try:
1678
resource.put_annotations(json_["annotations"])
1679
db.session.commit()
1680
except json.JSONDecodeError:
1681
return flask.jsonify({"error": "Invalid annotations"}), 400
1682
1683
return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id})
1684
1685
1686
@app.route("/api/picture/<int:id>/update", methods=["POST"])
1687
def api_update_picture(id):
1688
resource = db.session.get(PictureResource, id)
1689
if resource is None:
1690
return flask.jsonify({"error": "Picture not found"}), 404
1691
current_user = db.session.get(User, flask.session.get("username"))
1692
if current_user is None:
1693
return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401
1694
if resource.author != current_user and not current_user.admin:
1695
return flask.jsonify({"error": "You are not the author of this picture"}), 403
1696
1697
title = flask.request.json.get("title", resource.title)
1698
description = flask.request.json.get("description", resource.description)
1699
origin_url = flask.request.json.get("origin_url", resource.origin_url)
1700
licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences])
1701
nature_id = flask.request.json.get("nature", resource.nature_id)
1702
1703
if not title:
1704
return flask.jsonify({"error": "Give a title"}), 400
1705
1706
if not description:
1707
description = ""
1708
1709
if not nature_id:
1710
return flask.jsonify({"error": "Give a picture type"}), 400
1711
1712
if not licence_ids:
1713
return flask.jsonify({"error": "Give licences"}), 400
1714
1715
licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids]
1716
1717
if not any(licence.free for licence in licences):
1718
return flask.jsonify({"error": "Use at least one free licence"}), 400
1719
1720
resource.title = title
1721
resource.description = description
1722
resource.origin_url = origin_url
1723
resource.licences = licences
1724
resource.nature = db.session.get(PictureNature, nature_id)
1725
1726
db.session.commit()
1727
1728
return flask.jsonify({"message": "Picture updated successfully"})
1729
1730
1731
@app.route("/api/picture/<int:id>/delete", methods=["POST"])
1732
def api_delete_picture(id):
1733
resource = db.session.get(PictureResource, id)
1734
if resource is None:
1735
return flask.jsonify({"error": "Picture not found"}), 404
1736
current_user = db.session.get(User, flask.session.get("username"))
1737
if current_user is None:
1738
return flask.jsonify({"error": "You must be logged in to delete pictures"}), 401
1739
if resource.author != current_user and not current_user.admin:
1740
return flask.jsonify({"error": "You are not the author of this picture"}), 403
1741
1742
PictureInGallery.query.filter_by(resource=resource).delete()
1743
db.session.delete(resource)
1744
db.session.commit()
1745
1746
return flask.jsonify({"message": "Picture deleted successfully"})
1747
1748
1749
@app.route("/api/picture/<int:id>/put-file", methods=["POST"])
1750
def api_put_file(id):
1751
resource = db.session.get(PictureResource, id)
1752
if resource is None:
1753
return flask.jsonify({"error": "Picture not found"}), 404
1754
current_user = db.session.get(User, flask.session.get("username"))
1755
if current_user is None:
1756
return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401
1757
if resource.author != current_user and not current_user.admin:
1758
return flask.jsonify({"error": "You are not the author of this picture"}), 403
1759
1760
file = flask.request.files["file"]
1761
1762
if not file or not file.filename:
1763
return flask.jsonify({"error": "Provide a file"}), 400
1764
1765
if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml":
1766
return flask.jsonify({"error": "Only images are supported"}), 400
1767
1768
file_path = path.join(config.DATA_PATH, "pictures", str(resource.id))
1769
file.save(file_path)
1770
pil_image = Image.open(file_path)
1771
resource.width, resource.height = pil_image.size
1772
pil_image = make_thumbnail(pil_image)
1773
pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS)
1774
1775
return flask.jsonify({"message": "File uploaded successfully"})
1776
1777
1778
@app.route("/api/picture/<int:id>/rate", methods=["POST"])
1779
def api_rate_picture(id):
1780
resource = db.session.get(PictureResource, id)
1781
if resource is None:
1782
flask.abort(404)
1783
1784
current_user = db.session.get(User, flask.session.get("username"))
1785
if current_user is None:
1786
flask.abort(401)
1787
1788
rating = int(flask.request.json.get("rating", 0))
1789
1790
if not rating:
1791
# Delete the existing rating
1792
if PictureRating.query.filter_by(resource=resource, user=current_user).first():
1793
db.session.delete(PictureRating.query.filter_by(resource=resource,
1794
user=current_user).first())
1795
db.session.commit()
1796
1797
return flask.jsonify({"message": "Existing rating removed"})
1798
1799
if not 1 <= rating <= 5:
1800
flask.flash("Invalid rating")
1801
return flask.jsonify({"error": "Invalid rating"}), 400
1802
1803
if PictureRating.query.filter_by(resource=resource, user=current_user).first():
1804
PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating
1805
else:
1806
# Create a new rating
1807
db.session.add(PictureRating(resource, current_user, rating))
1808
1809
db.session.commit()
1810
1811
return flask.jsonify({"message": "Rating saved"})
1812
1813
1814
@app.route("/api/gallery/<int:id>/")
1815
def api_gallery(id):
1816
gallery = db.session.get(Gallery, id)
1817
if gallery is None:
1818
flask.abort(404)
1819
1820
json_gallery = {
1821
"id": gallery.id,
1822
"title": gallery.title,
1823
"description": gallery.description,
1824
"owner": gallery.owner_name,
1825
"users": [user.username for user in gallery.users],
1826
}
1827
1828
return flask.jsonify(json_gallery)
1829
1830
1831
@app.route("/api/gallery/<int:id>/edit", methods=["POST"])
1832
def api_edit_gallery(id):
1833
gallery = db.session.get(Gallery, id)
1834
if gallery is None:
1835
flask.abort(404)
1836
1837
current_user = db.session.get(User, flask.session.get("username"))
1838
if current_user is None:
1839
flask.abort(401)
1840
1841
if current_user != gallery.owner and not current_user.admin:
1842
flask.abort(403)
1843
1844
title = flask.request.json.get("title", gallery.title)
1845
description = flask.request.json.get("description", gallery.description)
1846
1847
if not title:
1848
return flask.jsonify({"error": "Give a title"}), 400
1849
1850
if not description:
1851
description = ""
1852
1853
gallery.title = title
1854
gallery.description = description
1855
1856
db.session.commit()
1857
1858
return flask.jsonify({"message": "Gallery updated successfully"})
1859
1860
1861
@app.route("/api/new-gallery", methods=["POST"])
1862
def api_new_gallery():
1863
if "username" not in flask.session:
1864
return flask.jsonify({"error": "You must be logged in to create galleries"}), 401
1865
1866
title = flask.request.json.get("title")
1867
description = flask.request.json.get("description", "")
1868
1869
if not title:
1870
return flask.jsonify({"error": "Give a title"}), 400
1871
1872
gallery = Gallery(title, description, db.session.get(User, flask.session["username"]))
1873
db.session.add(gallery)
1874
db.session.commit()
1875
1876
return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id})
1877
1878
1879
@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"])
1880
def api_gallery_add_picture(id):
1881
gallery = db.session.get(Gallery, id)
1882
if gallery is None:
1883
flask.abort(404)
1884
1885
if "username" not in flask.session:
1886
return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401
1887
1888
current_user = db.session.get(User, flask.session.get("username"))
1889
1890
if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():
1891
return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403
1892
1893
picture_id = flask.request.json.get("picture_id")
1894
1895
try:
1896
picture_id = int(picture_id)
1897
except ValueError:
1898
return flask.jsonify({"error": "Invalid picture ID"}), 400
1899
1900
picture = db.session.get(PictureResource, picture_id)
1901
if picture is None:
1902
return flask.jsonify({"error": "The picture doesn't exist"}), 404
1903
1904
if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first():
1905
return flask.jsonify({"error": "This picture is already in the gallery"}), 400
1906
1907
db.session.add(PictureInGallery(picture, gallery))
1908
1909
db.session.commit()
1910
1911
return flask.jsonify({"message": "Picture added to gallery"})
1912
1913
1914
@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"])
1915
def api_gallery_remove_picture(id):
1916
gallery = db.session.get(Gallery, id)
1917
if gallery is None:
1918
flask.abort(404)
1919
1920
if "username" not in flask.session:
1921
return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401
1922
1923
current_user = db.session.get(User, flask.session.get("username"))
1924
1925
if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():
1926
return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403
1927
1928
picture_id = flask.request.json.get("picture_id")
1929
1930
try:
1931
picture_id = int(picture_id)
1932
except ValueError:
1933
return flask.jsonify({"error": "Invalid picture ID"}), 400
1934
1935
picture = db.session.get(PictureResource, picture_id)
1936
if picture is None:
1937
return flask.jsonify({"error": "The picture doesn't exist"}), 404
1938
1939
picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first()
1940
if picture_in_gallery is None:
1941
return flask.jsonify({"error": "This picture isn't in the gallery"}), 400
1942
1943
db.session.delete(picture_in_gallery)
1944
1945
db.session.commit()
1946
1947
return flask.jsonify({"message": "Picture removed from gallery"})
1948
1949
1950
@app.route("/api/gallery/<int:id>/users/add", methods=["POST"])
1951
def api_gallery_add_user(id):
1952
gallery = db.session.get(Gallery, id)
1953
if gallery is None:
1954
flask.abort(404)
1955
1956
current_user = db.session.get(User, flask.session.get("username"))
1957
if current_user is None:
1958
flask.abort(401)
1959
1960
if current_user != gallery.owner and not current_user.admin:
1961
flask.abort(403)
1962
1963
username = flask.request.json.get("username")
1964
if username == gallery.owner_name:
1965
return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400
1966
1967
user = db.session.get(User, username)
1968
if user is None:
1969
return flask.jsonify({"error": "User not found"}), 404
1970
1971
if UserInGallery.query.filter_by(user=user, gallery=gallery).first():
1972
return flask.jsonify({"error": "User is already in the gallery"}), 400
1973
1974
db.session.add(UserInGallery(user, gallery))
1975
1976
db.session.commit()
1977
1978
return flask.jsonify({"message": "User added to gallery"})
1979
1980
1981
@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"])
1982
def api_gallery_remove_user(id):
1983
gallery = db.session.get(Gallery, id)
1984
if gallery is None:
1985
flask.abort(404)
1986
1987
current_user = db.session.get(User, flask.session.get("username"))
1988
if current_user is None:
1989
flask.abort(401)
1990
1991
if current_user != gallery.owner and not current_user.admin:
1992
flask.abort(403)
1993
1994
username = flask.request.json.get("username")
1995
user = db.session.get(User, username)
1996
if user is None:
1997
return flask.jsonify({"error": "User not found"}), 404
1998
1999
user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first()
2000
if user_in_gallery is None:
2001
return flask.jsonify({"error": "User is not in the gallery"}), 400
2002
2003
db.session.delete(user_in_gallery)
2004
2005
db.session.commit()
2006
2007
return flask.jsonify({"message": "User removed from gallery"})
2008
2009
2010
@app.route("/api/gallery/<int:id>/delete", methods=["POST"])
2011
def api_delete_gallery(id):
2012
gallery = db.session.get(Gallery, id)
2013
if gallery is None:
2014
flask.abort(404)
2015
2016
current_user = db.session.get(User, flask.session.get("username"))
2017
if current_user is None:
2018
flask.abort(401)
2019
2020
if current_user != gallery.owner and not current_user.admin:
2021
flask.abort(403)
2022
2023
PictureInGallery.query.filter_by(gallery=gallery).delete()
2024
UserInGallery.query.filter_by(gallery=gallery).delete()
2025
db.session.delete(gallery)
2026
2027
db.session.commit()
2028
2029
return flask.jsonify({"message": "Gallery deleted"})
2030
2031