mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 04:42:17 +00:00
News app: provide models and minimal list and detail views.
This commit is contained in:
@@ -88,7 +88,14 @@ INSTALLED_APPS += [
|
||||
]
|
||||
|
||||
# Our Apps
|
||||
INSTALLED_APPS += ["ak", "users", "versions", "libraries", "mailing_list"]
|
||||
INSTALLED_APPS += [
|
||||
"ak",
|
||||
"users",
|
||||
"versions",
|
||||
"libraries",
|
||||
"mailing_list",
|
||||
"news",
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
|
||||
@@ -32,6 +32,7 @@ from libraries.views import (
|
||||
)
|
||||
from libraries.api import LibrarySearchView
|
||||
from mailing_list.views import MailingListView, MailingListDetailView
|
||||
from news.views import EntryDetailView, EntryListView
|
||||
from support.views import SupportView, ContactView
|
||||
from versions.api import VersionViewSet
|
||||
from versions.views import VersionList, VersionDetail
|
||||
@@ -108,6 +109,8 @@ urlpatterns = (
|
||||
name="mailing-list-detail",
|
||||
),
|
||||
path("mailing-list/", MailingListView.as_view(), name="mailing-list"),
|
||||
path("news/", EntryListView.as_view(), name="news"),
|
||||
path("news/<slug:slug>/", EntryDetailView.as_view(), name="news-detail"),
|
||||
path(
|
||||
"people/detail/",
|
||||
TemplateView.as_view(template_name="boost/people_detail.html"),
|
||||
@@ -158,16 +161,6 @@ urlpatterns = (
|
||||
TemplateView.as_view(template_name="review/review_process.html"),
|
||||
name="review-process",
|
||||
),
|
||||
path(
|
||||
"news/detail/",
|
||||
TemplateView.as_view(template_name="news/news_detail.html"),
|
||||
name="news_detail",
|
||||
),
|
||||
path(
|
||||
"news/",
|
||||
TemplateView.as_view(template_name="news/news_list.html"),
|
||||
name="news",
|
||||
),
|
||||
# support and contact views
|
||||
path("support/", SupportView.as_view(), name="support"),
|
||||
path(
|
||||
|
||||
0
news/__init__.py
Normal file
0
news/__init__.py
Normal file
10
news/admin.py
Normal file
10
news/admin.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Entry
|
||||
|
||||
|
||||
class EntryAdmin(admin.ModelAdmin):
|
||||
prepopulated_fields = {"slug": ["title"]}
|
||||
|
||||
|
||||
admin.site.register(Entry, EntryAdmin)
|
||||
6
news/apps.py
Normal file
6
news/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NewsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "news"
|
||||
142
news/migrations/0001_initial.py
Normal file
142
news/migrations/0001_initial.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# Generated by Django 4.2 on 2023-05-12 18:34
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Entry",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("slug", models.SlugField()),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, default="")),
|
||||
("external_url", models.URLField(blank=True, default="")),
|
||||
("image", models.ImageField(blank=True, null=True, upload_to="news")),
|
||||
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
("publish_at", models.DateTimeField(default=django.utils.timezone.now)),
|
||||
(
|
||||
"author",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Entries",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BlogPost",
|
||||
fields=[
|
||||
(
|
||||
"entry_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="news.entry",
|
||||
),
|
||||
),
|
||||
("body", models.TextField()),
|
||||
("abstract", models.CharField(max_length=256)),
|
||||
],
|
||||
bases=("news.entry",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Link",
|
||||
fields=[
|
||||
(
|
||||
"entry_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="news.entry",
|
||||
),
|
||||
),
|
||||
],
|
||||
bases=("news.entry",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Poll",
|
||||
fields=[
|
||||
(
|
||||
"entry_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="news.entry",
|
||||
),
|
||||
),
|
||||
],
|
||||
bases=("news.entry",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Video",
|
||||
fields=[
|
||||
(
|
||||
"entry_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="news.entry",
|
||||
),
|
||||
),
|
||||
],
|
||||
bases=("news.entry",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PollChoice",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("wording", models.CharField(max_length=200)),
|
||||
("order", models.PositiveIntegerField()),
|
||||
("votes", models.ManyToManyField(to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"poll",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="news.poll"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
news/migrations/__init__.py
Normal file
0
news/migrations/__init__.py
Normal file
72
news/models.py
Normal file
72
news/models.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Entry(models.Model):
|
||||
"""A news entry.
|
||||
|
||||
Please note that this is a concrete class with its own DB table. Children
|
||||
of this class have their own table with their own attributes, plus a 1-1
|
||||
relationship with their parent.
|
||||
|
||||
"""
|
||||
|
||||
slug = models.SlugField()
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, default="")
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
external_url = models.URLField(blank=True, default="")
|
||||
image = models.ImageField(upload_to="news", null=True, blank=True)
|
||||
created_at = models.DateTimeField(default=now)
|
||||
publish_at = models.DateTimeField(default=now)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Entries"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.author}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def published(self):
|
||||
return self.publish_at <= now()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("news-detail", args=[self.slug])
|
||||
|
||||
|
||||
class BlogPost(Entry):
|
||||
body = models.TextField()
|
||||
abstract = models.CharField(max_length=256)
|
||||
# Possible extra fields: RSS feed? banner? keywords?
|
||||
|
||||
|
||||
class Link(Entry):
|
||||
pass
|
||||
|
||||
|
||||
class Video(Entry):
|
||||
pass
|
||||
# Possible extra fields: length? quality?
|
||||
|
||||
|
||||
class Poll(Entry):
|
||||
pass
|
||||
# Possible extra fields: voting expiration date?
|
||||
|
||||
|
||||
class PollChoice(models.Model):
|
||||
poll = models.ForeignKey(Poll, on_delete=models.CASCADE)
|
||||
wording = models.CharField(max_length=200)
|
||||
order = models.PositiveIntegerField()
|
||||
votes = models.ManyToManyField(User)
|
||||
0
news/tests/__init__.py
Normal file
0
news/tests/__init__.py
Normal file
67
news/tests/test_models.py
Normal file
67
news/tests/test_models.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import datetime
|
||||
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
|
||||
from ..models import Entry, Poll
|
||||
|
||||
|
||||
def test_entry_str():
|
||||
entry = baker.make("Entry")
|
||||
assert str(entry) == f"{entry.title} by {entry.author}"
|
||||
|
||||
|
||||
def test_entry_generate_slug():
|
||||
author = baker.make("users.User")
|
||||
entry = Entry.objects.create(title="😀 Foo Bar Baz!@! +", author=author)
|
||||
assert entry.slug == "foo-bar-baz"
|
||||
|
||||
|
||||
def test_entry_slug_not_overwriten():
|
||||
author = baker.make("users.User")
|
||||
entry = Entry.objects.create(title="Foo!", author=author, slug="different")
|
||||
assert entry.slug == "different"
|
||||
|
||||
|
||||
def test_entry_published():
|
||||
entry = baker.make("Entry", publish_at=now())
|
||||
assert entry.published is True
|
||||
|
||||
|
||||
def test_entry_not_published():
|
||||
entry = baker.make("Entry", publish_at=now() + datetime.timedelta(minutes=1))
|
||||
assert entry.published is False
|
||||
|
||||
|
||||
def test_entry_absolute_url():
|
||||
entry = baker.make("Entry", slug="the-slug")
|
||||
assert entry.get_absolute_url() == "/news/the-slug/"
|
||||
|
||||
|
||||
def test_blogpost():
|
||||
blogpost = baker.make("BlogPost")
|
||||
assert isinstance(blogpost, Entry)
|
||||
assert Entry.objects.get(id=blogpost.id).blogpost == blogpost
|
||||
|
||||
|
||||
def test_link():
|
||||
link = baker.make("Link")
|
||||
assert isinstance(link, Entry)
|
||||
assert Entry.objects.get(id=link.id).link == link
|
||||
|
||||
|
||||
def test_video():
|
||||
video = baker.make("Video")
|
||||
assert isinstance(video, Entry)
|
||||
assert Entry.objects.get(id=video.id).video == video
|
||||
|
||||
|
||||
def test_poll():
|
||||
poll = baker.make("Poll")
|
||||
assert isinstance(poll, Entry)
|
||||
assert Entry.objects.get(id=poll.id).poll == poll
|
||||
|
||||
|
||||
def test_poll_choice():
|
||||
choice = baker.make("PollChoice")
|
||||
assert isinstance(choice.poll, Poll)
|
||||
72
news/tests/test_views.py
Normal file
72
news/tests/test_views.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import datetime
|
||||
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
|
||||
|
||||
def test_entry_list(tp):
|
||||
"""List published news."""
|
||||
yesterday_news = baker.make(
|
||||
"Entry", title="old news", publish_at=now() - datetime.timedelta(days=1)
|
||||
)
|
||||
today_news = baker.make("Entry", title="current news", publish_at=now().today())
|
||||
tomorrow_news = baker.make(
|
||||
"Entry", title="future news", publish_at=now() + datetime.timedelta(days=1)
|
||||
)
|
||||
|
||||
response = tp.get("news")
|
||||
|
||||
tp.response_200(response)
|
||||
expected = [today_news, yesterday_news]
|
||||
assert list(response.context.get("entry_list", [])) == expected
|
||||
|
||||
content = str(response.content)
|
||||
for n in expected:
|
||||
assert n.get_absolute_url() in content
|
||||
assert n.title in content
|
||||
|
||||
assert tomorrow_news.get_absolute_url() not in content
|
||||
assert tomorrow_news.title not in content
|
||||
|
||||
|
||||
def test_news_detail(tp):
|
||||
"""Browse details for a given news entry."""
|
||||
a_past_date = now() - datetime.timedelta(hours=10)
|
||||
news = baker.make("Entry", publish_at=a_past_date)
|
||||
url = tp.reverse("news-detail", news.slug)
|
||||
|
||||
response = tp.get(url)
|
||||
tp.response_200(response)
|
||||
|
||||
content = str(response.content)
|
||||
assert news.title in content
|
||||
assert news.description in content
|
||||
|
||||
# no next nor prev links
|
||||
assert "newer entries" not in content.lower()
|
||||
assert "older entries" not in content.lower()
|
||||
|
||||
# create an older news
|
||||
older_date = a_past_date - datetime.timedelta(hours=1)
|
||||
older = baker.make("Entry", publish_at=older_date)
|
||||
|
||||
response = tp.get(url)
|
||||
tp.response_200(response)
|
||||
|
||||
content = str(response.content)
|
||||
assert "newer entries" not in content.lower()
|
||||
assert "older entries" in content.lower()
|
||||
assert older.get_absolute_url() in content
|
||||
|
||||
# create a newer news, but still older than now so it's shown
|
||||
newer_date = a_past_date + datetime.timedelta(hours=1)
|
||||
assert newer_date < now()
|
||||
newer = baker.make("Entry", publish_at=newer_date)
|
||||
|
||||
response = tp.get(url)
|
||||
tp.response_200(response)
|
||||
|
||||
content = str(response.content)
|
||||
assert "newer entries" in content.lower()
|
||||
assert "older entries" in content.lower()
|
||||
assert newer.get_absolute_url() in content
|
||||
34
news/views.py
Normal file
34
news/views.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.views.generic import DetailView, ListView
|
||||
from django.utils.timezone import now
|
||||
|
||||
from .models import Entry
|
||||
|
||||
|
||||
def get_published_or_none(sibling_getter):
|
||||
"""Helper method to get next/prev published sibling of a given entry."""
|
||||
try:
|
||||
result = sibling_getter(publish_at__lte=now())
|
||||
except Entry.DoesNotExist:
|
||||
result = None
|
||||
return result
|
||||
|
||||
|
||||
class EntryListView(ListView):
|
||||
model = Entry
|
||||
template_name = "news/list.html"
|
||||
ordering = ["-publish_at"]
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(publish_at__lte=now())
|
||||
|
||||
|
||||
class EntryDetailView(DetailView):
|
||||
model = Entry
|
||||
template_name = "news/detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["next"] = get_published_or_none(self.object.get_next_by_publish_at)
|
||||
context["prev"] = get_published_or_none(self.object.get_previous_by_publish_at)
|
||||
return context
|
||||
46
templates/news/detail.html
Normal file
46
templates/news/detail.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb used on filtered views -->
|
||||
<div class="p-3 md:p-0">
|
||||
<a class="text-orange" href="{% url "news" %}">News</a> > {{ entry.title }}
|
||||
</div>
|
||||
|
||||
<!-- end breadcrumb -->
|
||||
<div class="py-0 px-3 mb-3 md:py-6 md:px-0">
|
||||
<div class="py-16 md:mx-auto md:w-3/4">
|
||||
<h1 class="text-4xl">{{ entry.title }}</h1>
|
||||
|
||||
<p class="mt-0 text-sm font-light">{{ entry.publish_at|date:"M jS, Y" }}</p>
|
||||
|
||||
<p>{{ entry.description|linebreaks }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex py-8 my-5 space-x-4 border-t border-b md:mx-auto md:w-3/4 border-slate">
|
||||
<span class="inline-block">Share:</span>
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_Facebook-logo.svg' %}" alt="Facebook" />
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_Twitter-logo.svg' %}" alt="Twitter" />
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_Linkedin-logo.svg' %}" alt="Linkedin" />
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_email.svg' %}" alt="Email" />
|
||||
</div>
|
||||
|
||||
{% if next or prev %}
|
||||
<div class="block flex my-16 md:mx-auto md:w-3/4">
|
||||
{% if next %}
|
||||
<div class="w-1/2 text-left">
|
||||
<a href="{{ next.get_absolute_url }}" class="py-2 px-4 text-sm font-medium uppercase rounded-md border border-slate text-orange">< Newer Entries</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if prev %}
|
||||
<div class="w-1/2 text-right">
|
||||
<a href="{{ prev.get_absolute_url }}" class="py-2 px-4 text-sm font-medium uppercase rounded-md border border-slate text-orange">Older Entries ></a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,16 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="py-0 px-3 mb-3 md:py-6 md:px-0">
|
||||
<div class=" md:w-full">
|
||||
<div class="md:w-full">
|
||||
<h1 class="text-4xl">Latest Stories</h1>
|
||||
<p class="mt-0 text-xl">
|
||||
Keep up with current information from Boost and our community. (Under construction)
|
||||
</p>
|
||||
|
||||
<div class="divide-y divide-gray-300">
|
||||
{% if entry_list %} {% comment %}New functionality{% endcomment %}
|
||||
|
||||
{% for entry in entry_list %}
|
||||
<div class="flex py-4">
|
||||
<div class="py-5 w-1/6">
|
||||
<h5>{{ entry.publish_at|date:"M jS, Y" }}</h5>
|
||||
</div>
|
||||
<div class="w-5/6 text-xl">
|
||||
<p><a href="{{ entry.get_absolute_url }}">{{ entry.title }}</a></p>
|
||||
<div class="space-x-8 uppercase">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
|
||||
<div class="flex py-4">
|
||||
<div class="py-5 w-1/6">
|
||||
<h5>May 10th, 2023</h5>
|
||||
@@ -38,6 +55,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,64 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb used on filtered views -->
|
||||
<div class="p-3 md:p-0">
|
||||
<a class="text-orange" href="{% url "news" %}">News</a> > Boost has moved download to JFrog Artifactory
|
||||
</div>
|
||||
|
||||
<!-- end breadcrumb -->
|
||||
<div class="py-0 px-3 mb-3 md:py-6 md:px-0">
|
||||
<div class="py-16 md:mx-auto md:w-3/4">
|
||||
<h1 class="text-4xl">
|
||||
Boost has moved downloads to JFrog Artifactory
|
||||
</h1>
|
||||
|
||||
<p class="mt-0 text-sm font-light">
|
||||
April 29th, 2022 18:00 GMT
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The service that Boost uses to serve up its releases, Bintray.com is being retired by JFrog on the 1st of
|
||||
May. Fortunately for Boost, they have a new service, called JFrog.Arifactory, which we have transitioned to.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
For the users of Boost, the only difference is that there is a new URL to download releases and snapshots.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Instead of: <a href="#" class="text-orange">https://dl.bintray.com/boostorg/release/</a> you should use
|
||||
<a href="#" class="text-orange">https://boostorg.jfrog.io/artifactory/main/release/</a> to retrieve boost
|
||||
releases.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Note: The pre-1.64 Boost releases are still available via Sourceforge.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Thank you to JFrog for all your past and current support.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex py-8 my-5 space-x-4 border-t border-b md:mx-auto md:w-3/4 border-slate">
|
||||
<span class="inline-block">Share:</span>
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_Facebook-logo.svg' %}" alt="Facebook" />
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_Twitter-logo.svg' %}" alt="Twitter" />
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_Linkedin-logo.svg' %}" alt="Linkedin" />
|
||||
<img class="inline-block" src="{% static 'img/icons/icon_email.svg' %}" alt="Email" />
|
||||
</div>
|
||||
|
||||
<div class="block flex my-16 md:mx-auto md:w-3/4">
|
||||
<div class="w-1/2 text-left">
|
||||
<a href="#" class="py-2 px-4 text-sm font-medium uppercase rounded-md border border-slate text-orange">< Newer Entries</a>
|
||||
</div>
|
||||
<div class="w-1/2 text-right">
|
||||
<a href="#" class="py-2 px-4 text-sm font-medium uppercase rounded-md border border-slate text-orange">Older Entries ></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user