diff --git a/config/settings.py b/config/settings.py index f8a0a412..5713c22e 100755 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/config/urls.py b/config/urls.py index 60dcafdd..84a205ad 100755 --- a/config/urls.py +++ b/config/urls.py @@ -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//", 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( diff --git a/news/__init__.py b/news/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/admin.py b/news/admin.py new file mode 100644 index 00000000..52a14234 --- /dev/null +++ b/news/admin.py @@ -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) diff --git a/news/apps.py b/news/apps.py new file mode 100644 index 00000000..e50c4540 --- /dev/null +++ b/news/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NewsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "news" diff --git a/news/migrations/0001_initial.py b/news/migrations/0001_initial.py new file mode 100644 index 00000000..7c842f46 --- /dev/null +++ b/news/migrations/0001_initial.py @@ -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" + ), + ), + ], + ), + ] diff --git a/news/migrations/__init__.py b/news/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/models.py b/news/models.py new file mode 100644 index 00000000..1ee788b1 --- /dev/null +++ b/news/models.py @@ -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) diff --git a/news/tests/__init__.py b/news/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/tests/test_models.py b/news/tests/test_models.py new file mode 100644 index 00000000..129bf6bc --- /dev/null +++ b/news/tests/test_models.py @@ -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) diff --git a/news/tests/test_views.py b/news/tests/test_views.py new file mode 100644 index 00000000..c9c0a780 --- /dev/null +++ b/news/tests/test_views.py @@ -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 diff --git a/news/views.py b/news/views.py new file mode 100644 index 00000000..66c643bc --- /dev/null +++ b/news/views.py @@ -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 diff --git a/templates/news/detail.html b/templates/news/detail.html new file mode 100644 index 00000000..7b01e520 --- /dev/null +++ b/templates/news/detail.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block content %} + +
+ News > {{ entry.title }} +
+ + +
+
+

{{ entry.title }}

+ +

{{ entry.publish_at|date:"M jS, Y" }}

+ +

{{ entry.description|linebreaks }}

+ +
+ +
+ Share: + Facebook + Twitter + Linkedin + Email +
+ + {% if next or prev %} +
+ {% if next %} + + {% endif %} + {% if prev %} + + {% endif %} +
+ {% endif %} + +
+{% endblock %} diff --git a/templates/news/news_list.html b/templates/news/list.html similarity index 65% rename from templates/news/news_list.html rename to templates/news/list.html index d55835e6..44d2687e 100644 --- a/templates/news/news_list.html +++ b/templates/news/list.html @@ -1,16 +1,33 @@ {% extends "base.html" %} - +{% load i18n %} {% load static %} {% block content %}
-
+

Latest Stories

Keep up with current information from Boost and our community. (Under construction)

+ {% if entry_list %} {% comment %}New functionality{% endcomment %} + + {% for entry in entry_list %} +
+
+
{{ entry.publish_at|date:"M jS, Y" }}
+
+ +
+ {% endfor %} + + {% else %} +
May 10th, 2023
@@ -38,6 +55,8 @@
+ + {% endif %}
diff --git a/templates/news/news_detail.html b/templates/news/news_detail.html deleted file mode 100644 index 7513d5ab..00000000 --- a/templates/news/news_detail.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} - -{% load static %} - -{% block content %} - -
- News > Boost has moved download to JFrog Artifactory -
- - -
-
-

- Boost has moved downloads to JFrog Artifactory -

- -

- April 29th, 2022 18:00 GMT -

- -

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

- -

- For the users of Boost, the only difference is that there is a new URL to download releases and snapshots. -

- -

- Instead of: https://dl.bintray.com/boostorg/release/ you should use - https://boostorg.jfrog.io/artifactory/main/release/ to retrieve boost - releases. -

- -

- Note: The pre-1.64 Boost releases are still available via Sourceforge. -

- -

- Thank you to JFrog for all your past and current support. -

- -
- -
- Share: - Facebook - Twitter - Linkedin - Email -
- - -
-{% endblock %}