fix: video pagination
This commit is contained in:
+20
-3
@@ -8,19 +8,36 @@ from .models import SiteBranding, SiteContact
|
||||
@admin.register(SiteBranding)
|
||||
class SiteBrandingAdmin(admin.ModelAdmin):
|
||||
fieldsets = (
|
||||
("Icon", {"fields": ("icon", "icon_alt")}),
|
||||
(
|
||||
"Brand assets",
|
||||
{
|
||||
"fields": (
|
||||
"icon",
|
||||
"icon_alt",
|
||||
"website_icon",
|
||||
"hero_logo",
|
||||
"hero_logo_alt",
|
||||
),
|
||||
"description": (
|
||||
"Manage each placement independently. Removing an upload restores "
|
||||
"the built-in Tecvico asset for that placement."
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Sizes",
|
||||
{
|
||||
"fields": ("navbar_icon_size", "footer_icon_size"),
|
||||
"description": "Square dimensions in pixels for each placement.",
|
||||
"description": (
|
||||
"Set the logo height for each placement. Its width is calculated "
|
||||
"automatically so the uploaded image is never cropped or stretched."
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Appearance",
|
||||
{
|
||||
"fields": (
|
||||
"object_fit",
|
||||
"show_border",
|
||||
"border_width",
|
||||
"border_color",
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.15 on 2026-08-01 13:13
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_alter_sitebranding_border_color'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='hero_logo',
|
||||
field=models.ImageField(blank=True, help_text='Large brand artwork shown in the homepage hero. Leave empty to use the default hero artwork.', null=True, upload_to='branding/hero/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='hero_logo_alt',
|
||||
field=models.CharField(blank=True, default='Tecvico showcase', help_text='Accessible description for the homepage hero logo.', max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sitebranding',
|
||||
name='website_icon',
|
||||
field=models.FileField(blank=True, help_text='Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, JPG, or WebP file. Leave empty to use the default Tecvico icon.', null=True, upload_to='branding/icons/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=('ico', 'png', 'svg', 'jpg', 'jpeg', 'webp'))]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sitebranding',
|
||||
name='icon',
|
||||
field=models.ImageField(blank=True, help_text='Brand logo shown in the site navigation and footer. Leave empty to use the default logo.', null=True, upload_to='branding/', verbose_name='Header and footer logo'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.15 on 2026-08-01 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_sitebranding_hero_logo_sitebranding_hero_logo_alt_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='sitebranding',
|
||||
name='footer_icon_size',
|
||||
field=models.PositiveSmallIntegerField(default=34, help_text='Maximum height in pixels for the footer logo. Width scales automatically.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sitebranding',
|
||||
name='navbar_icon_size',
|
||||
field=models.PositiveSmallIntegerField(default=26, help_text='Maximum height in pixels for the header logo. Width scales automatically.'),
|
||||
),
|
||||
]
|
||||
+36
-5
@@ -1,3 +1,4 @@
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -13,20 +14,50 @@ class SiteBranding(models.Model):
|
||||
upload_to="branding/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Logo shown in the site header and footer. Leave empty to use the default static icon.",
|
||||
verbose_name="Header and footer logo",
|
||||
help_text="Brand logo shown in the site navigation and footer. Leave empty to use the default logo.",
|
||||
)
|
||||
icon_alt = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
help_text="Alt text for the brand icon (decorative icons can stay empty).",
|
||||
)
|
||||
website_icon = models.FileField(
|
||||
upload_to="branding/icons/",
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[
|
||||
FileExtensionValidator(
|
||||
allowed_extensions=("ico", "png", "svg", "jpg", "jpeg", "webp")
|
||||
)
|
||||
],
|
||||
help_text=(
|
||||
"Icon shown in browser tabs and bookmarks. Use a square ICO, PNG, SVG, "
|
||||
"JPG, or WebP file. Leave empty to use the default Tecvico icon."
|
||||
),
|
||||
)
|
||||
hero_logo = models.ImageField(
|
||||
upload_to="branding/hero/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=(
|
||||
"Large brand artwork shown in the homepage hero. Leave empty to use "
|
||||
"the default hero artwork."
|
||||
),
|
||||
)
|
||||
hero_logo_alt = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
default="Tecvico showcase",
|
||||
help_text="Accessible description for the homepage hero logo.",
|
||||
)
|
||||
navbar_icon_size = models.PositiveSmallIntegerField(
|
||||
default=26,
|
||||
help_text="Width and height in pixels for the header icon.",
|
||||
help_text="Maximum height in pixels for the header logo. Width scales automatically.",
|
||||
)
|
||||
footer_icon_size = models.PositiveSmallIntegerField(
|
||||
default=34,
|
||||
help_text="Width and height in pixels for the footer icon.",
|
||||
help_text="Maximum height in pixels for the footer logo. Width scales automatically.",
|
||||
)
|
||||
show_border = models.BooleanField(
|
||||
default=False,
|
||||
@@ -61,9 +92,9 @@ class SiteBranding(models.Model):
|
||||
|
||||
def icon_style(self, size_px):
|
||||
parts = [
|
||||
f"width:{size_px}px",
|
||||
"width:auto",
|
||||
f"height:{size_px}px",
|
||||
f"object-fit:{self.object_fit}",
|
||||
"object-fit:contain",
|
||||
]
|
||||
if self.show_border:
|
||||
parts.append(f"border:{self.border_width}px solid {self.border_color}")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.context_processors import site_branding
|
||||
from apps.core.models import SiteBranding
|
||||
@@ -17,7 +18,9 @@ class SiteBrandingModelTests(TestCase):
|
||||
branding.border_width = 2
|
||||
branding.navbar_icon_size = 30
|
||||
style = branding.navbar_icon_style
|
||||
self.assertIn("width:30px", style)
|
||||
self.assertIn("width:auto", style)
|
||||
self.assertIn("height:30px", style)
|
||||
self.assertIn("object-fit:contain", style)
|
||||
self.assertIn("border:2px solid #ffffff", style)
|
||||
|
||||
def test_icon_style_omits_border_when_disabled(self):
|
||||
@@ -33,3 +36,26 @@ class SiteBrandingContextProcessorTests(TestCase):
|
||||
ctx = site_branding(request)
|
||||
self.assertIn("site_branding", ctx)
|
||||
self.assertIsInstance(ctx["site_branding"], SiteBranding)
|
||||
|
||||
|
||||
class SiteBrandingTemplateTests(TestCase):
|
||||
def test_custom_brand_assets_are_rendered_independently(self):
|
||||
branding = SiteBranding.load()
|
||||
branding.icon = "branding/navigation-logo.png"
|
||||
branding.website_icon = "branding/icons/site-icon.png"
|
||||
branding.hero_logo = "branding/hero/hero-logo.png"
|
||||
branding.hero_logo_alt = "Tecvico research platform"
|
||||
branding.save()
|
||||
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
|
||||
self.assertContains(response, 'src="/media/branding/navigation-logo.png"')
|
||||
self.assertContains(response, 'href="/media/branding/icons/site-icon.png"')
|
||||
self.assertContains(response, 'src="/media/branding/hero/hero-logo.png"')
|
||||
self.assertContains(response, 'alt="Tecvico research platform"')
|
||||
|
||||
def test_default_assets_remain_when_custom_assets_are_empty(self):
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
|
||||
self.assertContains(response, "images/tecvico/brand-logo.svg")
|
||||
self.assertContains(response, "images/tecvico/hero-image.svg")
|
||||
|
||||
@@ -80,6 +80,17 @@ class CustomPageVideoSectionTest(TestCase):
|
||||
|
||||
|
||||
class PageVideoTest(TestCase):
|
||||
def _create_page_videos(self, page, count):
|
||||
for index in range(count):
|
||||
PageVideo.objects.create(
|
||||
page=page,
|
||||
title=f"Video {index + 1}",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
order=index,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_contact_page_video(self):
|
||||
PageVideo.objects.create(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
@@ -103,6 +114,38 @@ class PageVideoTest(TestCase):
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
self.assertContains(response, "Tutorial")
|
||||
|
||||
def test_page_shows_three_video_preview_and_archive_link(self):
|
||||
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
|
||||
|
||||
response = self.client.get(reverse("pages:faq"))
|
||||
|
||||
self.assertEqual(response.content.count(b"youtube-nocookie.com/embed"), 3)
|
||||
self.assertContains(
|
||||
response,
|
||||
reverse("pages:video_archive", kwargs={"library": PageVideo.PAGE_FAQ}),
|
||||
)
|
||||
self.assertContains(response, "More videos")
|
||||
|
||||
def test_page_video_archive_is_paginated(self):
|
||||
self._create_page_videos(PageVideo.PAGE_FAQ, 8)
|
||||
archive_url = reverse("pages:video_archive", kwargs={"library": PageVideo.PAGE_FAQ})
|
||||
|
||||
first_page = self.client.get(archive_url)
|
||||
second_page = self.client.get(archive_url, {"page": 2})
|
||||
|
||||
self.assertEqual(first_page.status_code, 200)
|
||||
self.assertEqual(first_page.content.count(b"youtube-nocookie.com/embed"), 6)
|
||||
self.assertContains(first_page, "Page 1 of 2")
|
||||
self.assertContains(first_page, "?page=2")
|
||||
self.assertEqual(second_page.content.count(b"youtube-nocookie.com/embed"), 2)
|
||||
self.assertContains(second_page, "Page 2 of 2")
|
||||
|
||||
def test_unknown_page_video_archive_returns_404(self):
|
||||
response = self.client.get(
|
||||
reverse("pages:video_archive", kwargs={"library": "unknown"})
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class ProductVideoTest(TestCase):
|
||||
def setUp(self):
|
||||
@@ -129,6 +172,30 @@ class ProductVideoTest(TestCase):
|
||||
self.assertContains(response, "Product Demo")
|
||||
self.assertContains(response, "video-block--full")
|
||||
|
||||
def test_product_video_preview_links_to_paginated_archive(self):
|
||||
for index in range(7):
|
||||
ProductVideo.objects.create(
|
||||
main_product=self.main_product,
|
||||
title=f"Product video {index + 1}",
|
||||
video_source="youtube",
|
||||
video_url="https://youtu.be/dQw4w9WgXcQ",
|
||||
order=index,
|
||||
is_active=True,
|
||||
)
|
||||
archive_url = reverse(
|
||||
"products:main_product_videos",
|
||||
kwargs={"main_slug": self.main_product.slug},
|
||||
)
|
||||
|
||||
detail_response = self.client.get(self.main_product.get_absolute_url())
|
||||
archive_response = self.client.get(archive_url)
|
||||
second_page = self.client.get(archive_url, {"page": 2})
|
||||
|
||||
self.assertEqual(detail_response.content.count(b"youtube-nocookie.com/embed"), 3)
|
||||
self.assertContains(detail_response, archive_url)
|
||||
self.assertEqual(archive_response.content.count(b"youtube-nocookie.com/embed"), 6)
|
||||
self.assertEqual(second_page.content.count(b"youtube-nocookie.com/embed"), 1)
|
||||
|
||||
|
||||
class VideoStyledBackgroundTest(TestCase):
|
||||
def test_styled_background_renders_panel(self):
|
||||
|
||||
@@ -9,5 +9,6 @@ urlpatterns = [
|
||||
path("about/", views.AboutView.as_view(), name="about"),
|
||||
path("faq/", views.FAQView.as_view(), name="faq"),
|
||||
path("contact/", views.ContactView.as_view(), name="contact"),
|
||||
path("videos/<slug:library>/", views.PageVideoArchiveView.as_view(), name="video_archive"),
|
||||
path("<slug>/", views.CustomPageView.as_view(), name="custom_page"),
|
||||
]
|
||||
|
||||
+69
-5
@@ -1,8 +1,10 @@
|
||||
import random
|
||||
|
||||
from django.db.models import Prefetch
|
||||
from django.http import Http404
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
@@ -21,6 +23,18 @@ from .models import (
|
||||
PageVideo,
|
||||
)
|
||||
|
||||
VIDEO_PREVIEW_LIMIT = 3
|
||||
VIDEO_ARCHIVE_PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _video_preview_context(queryset, archive_url):
|
||||
total = queryset.count()
|
||||
return {
|
||||
"videos": queryset[:VIDEO_PREVIEW_LIMIT],
|
||||
"videos_total_count": total,
|
||||
"videos_archive_url": archive_url if total > VIDEO_PREVIEW_LIMIT else "",
|
||||
}
|
||||
|
||||
|
||||
def _homepage_products_catalog_queryset():
|
||||
return (
|
||||
@@ -79,10 +93,14 @@ class FAQView(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["faq_videos"] = PageVideo.objects.filter(
|
||||
videos = PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_FAQ,
|
||||
is_active=True,
|
||||
).order_by("order")
|
||||
preview = _video_preview_context(videos, reverse("pages:video_archive", kwargs={"library": PageVideo.PAGE_FAQ}))
|
||||
ctx["faq_videos"] = preview["videos"]
|
||||
ctx["videos_total_count"] = preview["videos_total_count"]
|
||||
ctx["videos_archive_url"] = preview["videos_archive_url"]
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -95,16 +113,23 @@ class ContactView(View):
|
||||
return f"{a} + {b}"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
videos = PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
is_active=True,
|
||||
).order_by("order")
|
||||
preview = _video_preview_context(
|
||||
videos,
|
||||
reverse("pages:video_archive", kwargs={"library": PageVideo.PAGE_CONTACT}),
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": ContactForm(),
|
||||
"captcha_question": self._new_captcha(request),
|
||||
"contact_videos": PageVideo.objects.filter(
|
||||
page=PageVideo.PAGE_CONTACT,
|
||||
is_active=True,
|
||||
).order_by("order"),
|
||||
"contact_videos": preview["videos"],
|
||||
"videos_total_count": preview["videos_total_count"],
|
||||
"videos_archive_url": preview["videos_archive_url"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -168,3 +193,42 @@ class CustomPageView(DetailView):
|
||||
)
|
||||
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
|
||||
return ctx
|
||||
|
||||
|
||||
class PageVideoArchiveView(ListView):
|
||||
model = PageVideo
|
||||
template_name = "videos/archive.html"
|
||||
context_object_name = "videos"
|
||||
paginate_by = VIDEO_ARCHIVE_PAGE_SIZE
|
||||
|
||||
PAGE_CONFIG = {
|
||||
PageVideo.PAGE_CONTACT: ("Contact videos", "Guides and updates from the Tecvico team.", "pages:contact"),
|
||||
PageVideo.PAGE_FAQ: ("FAQ videos", "Video answers to common questions.", "pages:faq"),
|
||||
}
|
||||
|
||||
def get_page_config(self):
|
||||
try:
|
||||
return self.PAGE_CONFIG[self.kwargs["library"]]
|
||||
except KeyError as exc:
|
||||
raise Http404("Video library not found.") from exc
|
||||
|
||||
def get_queryset(self):
|
||||
self.get_page_config()
|
||||
return PageVideo.objects.filter(
|
||||
page=self.kwargs["library"],
|
||||
is_active=True,
|
||||
).order_by("order", "pk")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
title, description, source_url_name = self.get_page_config()
|
||||
context.update(
|
||||
{
|
||||
"library_title": title,
|
||||
"library_description": description,
|
||||
"library_back_url": reverse(source_url_name),
|
||||
"library_back_label": "Back to page",
|
||||
"pagination_range": context["paginator"].get_elided_page_range(context["page_obj"].number),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
@@ -11,6 +11,16 @@ urlpatterns = [
|
||||
views.ReleaseAssetDownloadView.as_view(),
|
||||
name="release_asset_download",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/videos/",
|
||||
views.ProductVideoArchiveView.as_view(),
|
||||
name="main_product_videos",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/<slug:sub_slug>/videos/",
|
||||
views.ProductVideoArchiveView.as_view(),
|
||||
name="sub_product_videos",
|
||||
),
|
||||
path(
|
||||
"<slug:main_slug>/",
|
||||
views.MainProductDetailView.as_view(),
|
||||
|
||||
+70
-2
@@ -2,6 +2,7 @@ import mimetypes
|
||||
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
@@ -9,6 +10,18 @@ from .models import Article, ArticleCitation, ArticleSection, MainProduct, Produ
|
||||
from .release_assets import FILE_FIELD_BY_ASSET_KEY, RELEASE_ASSET_KEYS, URL_FIELD_BY_ASSET_KEY
|
||||
from .release_context import build_archive_context, build_release_context
|
||||
|
||||
VIDEO_PREVIEW_LIMIT = 3
|
||||
VIDEO_ARCHIVE_PAGE_SIZE = 6
|
||||
|
||||
|
||||
def _product_video_preview(queryset, archive_url):
|
||||
total = queryset.count()
|
||||
return {
|
||||
"product_videos": queryset[:VIDEO_PREVIEW_LIMIT],
|
||||
"videos_total_count": total,
|
||||
"videos_archive_url": archive_url if total > VIDEO_PREVIEW_LIMIT else "",
|
||||
}
|
||||
|
||||
|
||||
class ReleaseAssetDownloadView(View):
|
||||
def get(self, request, version_id, asset):
|
||||
@@ -58,7 +71,13 @@ class MainProductDetailView(DetailView):
|
||||
context["articles"] = self.object.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
).all()
|
||||
context["product_videos"] = self.object.videos.filter(is_active=True).order_by("order")
|
||||
videos = self.object.videos.filter(is_active=True).order_by("order", "pk")
|
||||
context.update(
|
||||
_product_video_preview(
|
||||
videos,
|
||||
reverse("products:main_product_videos", kwargs={"main_slug": self.object.slug}),
|
||||
)
|
||||
)
|
||||
release_context = build_release_context(
|
||||
self.object.distribution,
|
||||
self.object.versions.filter(is_active=True),
|
||||
@@ -112,7 +131,16 @@ class SubProductDetailView(TemplateView):
|
||||
context["articles"] = sub_product.articles.prefetch_related(
|
||||
"sections", "citations"
|
||||
).all()
|
||||
context["product_videos"] = sub_product.videos.filter(is_active=True).order_by("order")
|
||||
videos = sub_product.videos.filter(is_active=True).order_by("order", "pk")
|
||||
context.update(
|
||||
_product_video_preview(
|
||||
videos,
|
||||
reverse(
|
||||
"products:sub_product_videos",
|
||||
kwargs={"main_slug": main_product.slug, "sub_slug": sub_product.slug},
|
||||
),
|
||||
)
|
||||
)
|
||||
context["siblings"] = (
|
||||
SubProduct.objects.filter(main_product=main_product, is_active=True)
|
||||
.exclude(pk=sub_product.pk)
|
||||
@@ -127,6 +155,46 @@ class SubProductDetailView(TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class ProductVideoArchiveView(ListView):
|
||||
model = ProductVideo
|
||||
template_name = "videos/archive.html"
|
||||
context_object_name = "videos"
|
||||
paginate_by = VIDEO_ARCHIVE_PAGE_SIZE
|
||||
|
||||
def get_parent(self):
|
||||
main_product = get_object_or_404(
|
||||
MainProduct,
|
||||
slug=self.kwargs["main_slug"],
|
||||
is_active=True,
|
||||
)
|
||||
sub_slug = self.kwargs.get("sub_slug")
|
||||
if sub_slug:
|
||||
return get_object_or_404(
|
||||
SubProduct,
|
||||
slug=sub_slug,
|
||||
main_product=main_product,
|
||||
is_active=True,
|
||||
)
|
||||
return main_product
|
||||
|
||||
def get_queryset(self):
|
||||
self.parent_object = self.get_parent()
|
||||
return self.parent_object.videos.filter(is_active=True).order_by("order", "pk")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
"library_title": f"{self.parent_object.name} videos",
|
||||
"library_description": "Tutorials, demonstrations, and technical guides.",
|
||||
"library_back_url": self.parent_object.get_absolute_url(),
|
||||
"library_back_label": f"Back to {self.parent_object.name}",
|
||||
"pagination_range": context["paginator"].get_elided_page_range(context["page_obj"].number),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class SubProductOlderVersionsView(TemplateView):
|
||||
template_name = "products/versions_archive.html"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user