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