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