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