Web platform for sharing free image data for ML and research

Homepage: https://datasets.roundabout-host.com

By using this site, you agree to have cookies stored on your device, strictly for functional purposes, such as storing your session and preferences.

Dismiss

 app.py

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