forked from mirrors/bookwyrm
commit
33a8864eb4
7 changed files with 282 additions and 33 deletions
|
@ -73,7 +73,10 @@ class Book(ActivitypubMixin, BookWyrmModel):
|
||||||
@property
|
@property
|
||||||
def alt_text(self):
|
def alt_text(self):
|
||||||
''' image alt test '''
|
''' image alt test '''
|
||||||
return '%s cover (%s)' % (self.title, self.edition_info)
|
text = '%s cover' % self.title
|
||||||
|
if self.edition_info:
|
||||||
|
text += ' (%s)' % self.edition_info
|
||||||
|
return text
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
||||||
|
|
|
@ -71,6 +71,9 @@ class ActivitypubFieldMixin:
|
||||||
return
|
return
|
||||||
|
|
||||||
key = self.get_activitypub_field()
|
key = self.get_activitypub_field()
|
||||||
|
# TODO: surely there's a better way
|
||||||
|
if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
|
||||||
|
key = 'actor'
|
||||||
if isinstance(activity.get(key), list):
|
if isinstance(activity.get(key), list):
|
||||||
activity[key] += formatted
|
activity[key] += formatted
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -59,6 +59,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
''' expose the type of status for the ui using activity type '''
|
''' expose the type of status for the ui using activity type '''
|
||||||
return self.activity_serializer.__name__
|
return self.activity_serializer.__name__
|
||||||
|
|
||||||
|
@property
|
||||||
|
def boostable(self):
|
||||||
|
''' you can't boost dms '''
|
||||||
|
return self.privacy in ['unlisted', 'public']
|
||||||
|
|
||||||
def to_replies(self, **kwargs):
|
def to_replies(self, **kwargs):
|
||||||
''' helper function for loading AP serialized replies to a status '''
|
''' helper function for loading AP serialized replies to a status '''
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
|
@ -127,8 +132,8 @@ class Comment(Status):
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
''' indicate the book in question for mastodon (or w/e) users '''
|
''' indicate the book in question for mastodon (or w/e) users '''
|
||||||
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
|
return '<p>%s</p><p>(comment on <a href="%s">"%s"</a>)</p>' % \
|
||||||
(self.book.remote_id, self.book.title)
|
(self.content, self.book.remote_id, self.book.title)
|
||||||
|
|
||||||
activity_serializer = activitypub.Comment
|
activity_serializer = activitypub.Comment
|
||||||
pure_type = 'Note'
|
pure_type = 'Note'
|
||||||
|
@ -143,7 +148,7 @@ class Quotation(Status):
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
''' indicate the book in question for mastodon (or w/e) users '''
|
''' indicate the book in question for mastodon (or w/e) users '''
|
||||||
return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % (
|
return '<p>"%s"<br>-- <a href="%s">"%s"</a></p><p>%s</p>' % (
|
||||||
self.quote,
|
self.quote,
|
||||||
self.book.remote_id,
|
self.book.remote_id,
|
||||||
self.book.title,
|
self.book.title,
|
||||||
|
@ -184,8 +189,7 @@ class Review(Status):
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
''' indicate the book in question for mastodon (or w/e) users '''
|
''' indicate the book in question for mastodon (or w/e) users '''
|
||||||
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
|
return self.content
|
||||||
(self.book.remote_id, self.book.title)
|
|
||||||
|
|
||||||
activity_serializer = activitypub.Review
|
activity_serializer = activitypub.Review
|
||||||
pure_type = 'Article'
|
pure_type = 'Article'
|
||||||
|
|
|
@ -91,6 +91,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
last_active_date = models.DateTimeField(auto_now=True)
|
last_active_date = models.DateTimeField(auto_now=True)
|
||||||
manually_approves_followers = fields.BooleanField(default=False)
|
manually_approves_followers = fields.BooleanField(default=False)
|
||||||
|
|
||||||
|
name_field = 'username'
|
||||||
@property
|
@property
|
||||||
def alt_text(self):
|
def alt_text(self):
|
||||||
''' alt text with username '''
|
''' alt text with username '''
|
||||||
|
|
|
@ -315,15 +315,19 @@ def handle_unfavorite(user, status):
|
||||||
|
|
||||||
def handle_boost(user, status):
|
def handle_boost(user, status):
|
||||||
''' a user wishes to boost a status '''
|
''' a user wishes to boost a status '''
|
||||||
|
# is it boostable?
|
||||||
|
if not status.boostable:
|
||||||
|
return
|
||||||
|
|
||||||
if models.Boost.objects.filter(
|
if models.Boost.objects.filter(
|
||||||
boosted_status=status, user=user).exists():
|
boosted_status=status, user=user).exists():
|
||||||
# you already boosted that.
|
# you already boosted that.
|
||||||
return
|
return
|
||||||
boost = models.Boost.objects.create(
|
boost = models.Boost.objects.create(
|
||||||
boosted_status=status,
|
boosted_status=status,
|
||||||
|
privacy=status.privacy,
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
boost.save()
|
|
||||||
|
|
||||||
boost_activity = boost.to_activity()
|
boost_activity = boost.to_activity()
|
||||||
broadcast(user, boost_activity)
|
broadcast(user, boost_activity)
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
|
|
||||||
{% with status.id|uuid as uuid %}
|
{% with status.id|uuid as uuid %}
|
||||||
<form name="boost" action="/boost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
<form name="boost" action="/boost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-small" type="submit">
|
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||||
<span class="icon icon-boost">
|
<span class="icon icon-boost">
|
||||||
<span class="is-sr-only">Boost status</span>
|
<span class="is-sr-only">Boost status</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,42 +1,275 @@
|
||||||
''' testing models '''
|
''' testing models '''
|
||||||
|
from io import BytesIO
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
|
|
||||||
|
|
||||||
class Status(TestCase):
|
class Status(TestCase):
|
||||||
|
''' lotta types of statuses '''
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
user = models.User.objects.create_user(
|
''' useful things for creating a status '''
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
|
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
|
||||||
book = models.Edition.objects.create(title='Example Edition')
|
self.book = models.Edition.objects.create(title='Test Edition')
|
||||||
|
|
||||||
models.Status.objects.create(user=user, content='Blah blah')
|
image_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
models.Comment.objects.create(user=user, content='content', book=book)
|
'../../static/images/default_avi.jpg')
|
||||||
models.Quotation.objects.create(
|
image = Image.open(image_file)
|
||||||
user=user, content='content', book=book, quote='blah')
|
output = BytesIO()
|
||||||
models.Review.objects.create(
|
image.save(output, format=image.format)
|
||||||
user=user, content='content', book=book, rating=3)
|
self.book.cover.save(
|
||||||
|
'test.jpg',
|
||||||
|
ContentFile(output.getvalue())
|
||||||
|
)
|
||||||
|
|
||||||
def test_status(self):
|
def test_status_generated_fields(self):
|
||||||
status = models.Status.objects.first()
|
''' setting remote id '''
|
||||||
|
status = models.Status.objects.create(content='bleh', user=self.user)
|
||||||
expected_id = 'https://%s/user/mouse/status/%d' % \
|
expected_id = 'https://%s/user/mouse/status/%d' % \
|
||||||
(settings.DOMAIN, status.id)
|
(settings.DOMAIN, status.id)
|
||||||
self.assertEqual(status.remote_id, expected_id)
|
self.assertEqual(status.remote_id, expected_id)
|
||||||
|
self.assertEqual(status.privacy, 'public')
|
||||||
|
|
||||||
def test_comment(self):
|
def test_replies(self):
|
||||||
comment = models.Comment.objects.first()
|
''' get a list of replies '''
|
||||||
expected_id = 'https://%s/user/mouse/comment/%d' % \
|
parent = models.Status.objects.create(content='hi', user=self.user)
|
||||||
(settings.DOMAIN, comment.id)
|
child = models.Status.objects.create(
|
||||||
self.assertEqual(comment.remote_id, expected_id)
|
content='hello', reply_parent=parent, user=self.user)
|
||||||
|
models.Review.objects.create(
|
||||||
|
content='hey', reply_parent=parent, user=self.user, book=self.book)
|
||||||
|
models.Status.objects.create(
|
||||||
|
content='hi hello', reply_parent=child, user=self.user)
|
||||||
|
|
||||||
def test_quotation(self):
|
replies = models.Status.replies(parent)
|
||||||
quotation = models.Quotation.objects.first()
|
self.assertEqual(replies.count(), 2)
|
||||||
expected_id = 'https://%s/user/mouse/quotation/%d' % \
|
self.assertEqual(replies.first(), child)
|
||||||
(settings.DOMAIN, quotation.id)
|
# should select subclasses
|
||||||
self.assertEqual(quotation.remote_id, expected_id)
|
self.assertIsInstance(replies.last(), models.Review)
|
||||||
|
|
||||||
def test_review(self):
|
def test_status_type(self):
|
||||||
review = models.Review.objects.first()
|
''' class name '''
|
||||||
expected_id = 'https://%s/user/mouse/review/%d' % \
|
self.assertEqual(models.Status().status_type, 'Note')
|
||||||
(settings.DOMAIN, review.id)
|
self.assertEqual(models.Review().status_type, 'Review')
|
||||||
self.assertEqual(review.remote_id, expected_id)
|
self.assertEqual(models.Quotation().status_type, 'Quotation')
|
||||||
|
self.assertEqual(models.Comment().status_type, 'Comment')
|
||||||
|
self.assertEqual(models.Boost().status_type, 'Boost')
|
||||||
|
|
||||||
|
def test_boostable(self):
|
||||||
|
''' can a status be boosted, based on privacy '''
|
||||||
|
self.assertTrue(models.Status(privacy='public').boostable)
|
||||||
|
self.assertTrue(models.Status(privacy='unlisted').boostable)
|
||||||
|
self.assertFalse(models.Status(privacy='followers').boostable)
|
||||||
|
self.assertFalse(models.Status(privacy='direct').boostable)
|
||||||
|
|
||||||
|
def test_to_replies(self):
|
||||||
|
''' activitypub replies collection '''
|
||||||
|
parent = models.Status.objects.create(content='hi', user=self.user)
|
||||||
|
child = models.Status.objects.create(
|
||||||
|
content='hello', reply_parent=parent, user=self.user)
|
||||||
|
models.Review.objects.create(
|
||||||
|
content='hey', reply_parent=parent, user=self.user, book=self.book)
|
||||||
|
models.Status.objects.create(
|
||||||
|
content='hi hello', reply_parent=child, user=self.user)
|
||||||
|
|
||||||
|
replies = parent.to_replies()
|
||||||
|
self.assertEqual(replies['id'], '%s/replies' % parent.remote_id)
|
||||||
|
self.assertEqual(replies['totalItems'], 2)
|
||||||
|
|
||||||
|
def test_status_to_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Status.objects.create(
|
||||||
|
content='test content', user=self.user)
|
||||||
|
activity = status.to_activity()
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Note')
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['sensitive'], False)
|
||||||
|
|
||||||
|
def test_status_to_activity_tombstone(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Status.objects.create(
|
||||||
|
content='test content', user=self.user,
|
||||||
|
deleted=True, deleted_date=timezone.now())
|
||||||
|
activity = status.to_activity()
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Tombstone')
|
||||||
|
self.assertFalse(hasattr(activity, 'content'))
|
||||||
|
|
||||||
|
def test_status_to_pure_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Status.objects.create(
|
||||||
|
content='test content', user=self.user)
|
||||||
|
activity = status.to_activity(pure=True)
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Note')
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['sensitive'], False)
|
||||||
|
self.assertEqual(activity['attachment'], [])
|
||||||
|
|
||||||
|
def test_generated_note_to_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.GeneratedNote.objects.create(
|
||||||
|
content='test content', user=self.user)
|
||||||
|
status.mention_books.set([self.book])
|
||||||
|
status.mention_users.set([self.user])
|
||||||
|
activity = status.to_activity()
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'GeneratedNote')
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['sensitive'], False)
|
||||||
|
self.assertEqual(len(activity['tag']), 2)
|
||||||
|
|
||||||
|
def test_generated_note_to_pure_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.GeneratedNote.objects.create(
|
||||||
|
content='test content', user=self.user)
|
||||||
|
status.mention_books.set([self.book])
|
||||||
|
status.mention_users.set([self.user])
|
||||||
|
activity = status.to_activity(pure=True)
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(
|
||||||
|
activity['content'],
|
||||||
|
'mouse test content <a href="%s">"Test Edition"</a>' % \
|
||||||
|
self.book.remote_id)
|
||||||
|
self.assertEqual(len(activity['tag']), 2)
|
||||||
|
self.assertEqual(activity['type'], 'Note')
|
||||||
|
self.assertEqual(activity['sensitive'], False)
|
||||||
|
self.assertIsInstance(activity['attachment'], list)
|
||||||
|
self.assertEqual(activity['attachment'][0].type, 'Image')
|
||||||
|
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||||
|
(settings.DOMAIN, self.book.cover.url))
|
||||||
|
self.assertEqual(
|
||||||
|
activity['attachment'][0].name, 'Test Edition cover')
|
||||||
|
|
||||||
|
def test_comment_to_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Comment.objects.create(
|
||||||
|
content='test content', user=self.user, book=self.book)
|
||||||
|
activity = status.to_activity()
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Comment')
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
|
||||||
|
|
||||||
|
def test_comment_to_pure_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Comment.objects.create(
|
||||||
|
content='test content', user=self.user, book=self.book)
|
||||||
|
activity = status.to_activity(pure=True)
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Note')
|
||||||
|
self.assertEqual(
|
||||||
|
activity['content'],
|
||||||
|
'<p>test content</p><p>' \
|
||||||
|
'(comment on <a href="%s">"Test Edition"</a>)</p>' %
|
||||||
|
self.book.remote_id)
|
||||||
|
self.assertEqual(activity['attachment'][0].type, 'Image')
|
||||||
|
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||||
|
(settings.DOMAIN, self.book.cover.url))
|
||||||
|
self.assertEqual(
|
||||||
|
activity['attachment'][0].name, 'Test Edition cover')
|
||||||
|
|
||||||
|
def test_quotation_to_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Quotation.objects.create(
|
||||||
|
quote='a sickening sense', content='test content',
|
||||||
|
user=self.user, book=self.book)
|
||||||
|
activity = status.to_activity()
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Quotation')
|
||||||
|
self.assertEqual(activity['quote'], 'a sickening sense')
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
|
||||||
|
|
||||||
|
def test_quotation_to_pure_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Quotation.objects.create(
|
||||||
|
quote='a sickening sense', content='test content',
|
||||||
|
user=self.user, book=self.book)
|
||||||
|
activity = status.to_activity(pure=True)
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Note')
|
||||||
|
self.assertEqual(
|
||||||
|
activity['content'],
|
||||||
|
'<p>"a sickening sense"<br>-- <a href="%s">"Test Edition"</a></p>' \
|
||||||
|
'<p>test content</p>' % self.book.remote_id)
|
||||||
|
self.assertEqual(activity['attachment'][0].type, 'Image')
|
||||||
|
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||||
|
(settings.DOMAIN, self.book.cover.url))
|
||||||
|
self.assertEqual(
|
||||||
|
activity['attachment'][0].name, 'Test Edition cover')
|
||||||
|
|
||||||
|
def test_review_to_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Review.objects.create(
|
||||||
|
name='Review name', content='test content', rating=3,
|
||||||
|
user=self.user, book=self.book)
|
||||||
|
activity = status.to_activity()
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Review')
|
||||||
|
self.assertEqual(activity['rating'], 3)
|
||||||
|
self.assertEqual(activity['name'], 'Review name')
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
|
||||||
|
|
||||||
|
def test_review_to_pure_activity(self):
|
||||||
|
''' subclass of the base model version with a "pure" serializer '''
|
||||||
|
status = models.Review.objects.create(
|
||||||
|
name='Review name', content='test content', rating=3,
|
||||||
|
user=self.user, book=self.book)
|
||||||
|
activity = status.to_activity(pure=True)
|
||||||
|
self.assertEqual(activity['id'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Article')
|
||||||
|
self.assertEqual(
|
||||||
|
activity['name'], 'Review of "%s" (3 stars): Review name' \
|
||||||
|
% self.book.title)
|
||||||
|
self.assertEqual(activity['content'], 'test content')
|
||||||
|
self.assertEqual(activity['attachment'][0].type, 'Image')
|
||||||
|
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||||
|
(settings.DOMAIN, self.book.cover.url))
|
||||||
|
self.assertEqual(
|
||||||
|
activity['attachment'][0].name, 'Test Edition cover')
|
||||||
|
|
||||||
|
def test_favorite(self):
|
||||||
|
''' fav a status '''
|
||||||
|
status = models.Status.objects.create(
|
||||||
|
content='test content', user=self.user)
|
||||||
|
fav = models.Favorite.objects.create(status=status, user=self.user)
|
||||||
|
|
||||||
|
# can't fav a status twice
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
models.Favorite.objects.create(status=status, user=self.user)
|
||||||
|
|
||||||
|
activity = fav.to_activity()
|
||||||
|
self.assertEqual(activity['type'], 'Like')
|
||||||
|
self.assertEqual(activity['actor'], self.user.remote_id)
|
||||||
|
self.assertEqual(activity['object'], status.remote_id)
|
||||||
|
|
||||||
|
def test_boost(self):
|
||||||
|
''' boosting, this one's a bit fussy '''
|
||||||
|
status = models.Status.objects.create(
|
||||||
|
content='test content', user=self.user)
|
||||||
|
boost = models.Boost.objects.create(
|
||||||
|
boosted_status=status, user=self.user)
|
||||||
|
activity = boost.to_activity()
|
||||||
|
self.assertEqual(activity['actor'], self.user.remote_id)
|
||||||
|
self.assertEqual(activity['object'], status.remote_id)
|
||||||
|
self.assertEqual(activity['type'], 'Announce')
|
||||||
|
self.assertEqual(activity, boost.to_activity(pure=True))
|
||||||
|
|
||||||
|
def test_notification(self):
|
||||||
|
''' a simple model '''
|
||||||
|
notification = models.Notification.objects.create(
|
||||||
|
user=self.user, notification_type='FAVORITE')
|
||||||
|
self.assertFalse(notification.read)
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
models.Notification.objects.create(
|
||||||
|
user=self.user, notification_type='GLORB')
|
||||||
|
|
Loading…
Reference in a new issue