News app: provide models and minimal list and detail views.

This commit is contained in:
Natalia
2023-05-12 15:59:55 -03:00
committed by nessita
parent 25521430f0
commit 929b82155b
15 changed files with 481 additions and 77 deletions

View File

@@ -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

View File

@@ -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
View File

10
news/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "news"

View 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"
),
),
],
),
]

View File

72
news/models.py Normal file
View 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
View File

67
news/tests/test_models.py Normal file
View 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
View 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
View 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

View 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 %}

View File

@@ -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>

View File

@@ -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 %}