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