initial commit

This commit is contained in:
Hugh Rundle 2024-01-04 11:54:56 +11:00
commit 53df5c5035
Signed by: hugh
GPG key ID: A7E35779918253F9
69 changed files with 4139 additions and 0 deletions

31
.env.example Normal file
View file

@ -0,0 +1,31 @@
DJANGO_SUPERUSER_USERNAME=""
DJANGO_SUPERUSER_PASSWORD=""
DJANGO_SUPERUSER_EMAIL=""
DOMAIN="localhost:8000"
LANGUAGE_CODE="en-AU"
TIME_ZONE="Australia/Melbourne"
# custom recipient email setting
ADMIN_EMAIL=""
# django email settings
DEFAULT_FROM_EMAIL="First Second <First.Second@example.org>"
EMAIL_HOST="smtp.fastmail.com"
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=465
EMAIL_USE_SSL=True
# database
POSTGRES_USER="ausglamr"
POSTGRES_PASSWORD=""
POSTGRES_DB="ausglamr"
PGPORT=5432
POSTGRES_HOST="db"
# mastodon
MASTODON_ACCESS_TOKEN=""
MASTODON_DOMAIN="https://example.com"

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
data
static

9
.pylintrc Normal file
View file

@ -0,0 +1,9 @@
[MAIN]
ignore=migrations
load-plugins=pylint.extensions.no_self_use
[MESSAGES CONTROL]
disable=E1101,E1135,E1136,E0307,R0903,R0901,R0902,W0707,W0406,R0401,R0801,C3001,import-error,C0301
[FORMAT]
max-line-length=88

6
Dockerfile Normal file
View file

@ -0,0 +1,6 @@
FROM python:3.9
ENV PYTHONUNBUFFERED 1
RUN mkdir /app /app/static
WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir

70
README.md Normal file
View file

@ -0,0 +1,70 @@
# Aus GLAMR
A django app running on Docker. Replaces _Aus GLAM Blogs_.
## Admin
Don't forget to add some Content Warnings for use by the Mastodon bot.
## CLI tool
Use `glamr-dev` to make your life easier (thanks to Mouse Reeve for the inspiration):
* announce
* check_feeds
* manage [django management command]
* makemigrations
* migrate
* queue_announcements
* send_weekly_email
And for dev work:
* black
* collectstatic
* createsuperuser
* pylint
* resetdb
* test
## Registration
- users can register a blog, group, event, newsletter, or Call for Papers.
- most of these ask for an "owner email" - this is optional but allows us to communicate with the person registering.
- all registrations should trigger an email to admin
- all must be approved before they are included
## Management commands
There are four commands:
- announce
- check_feeds
- queue_announcements
- send_weekly_email
These will not be triggered within the app - they should be called via cron jobs.
### announce
This announces the next queued announcement on Mastodon.
Run every 21 mins.
### check_feeds
This checks all blog feeds for any new posts, and adds them to the database as long as they don't have an exclusion tag and were not published during a time the blog was suspended.
Run every hour.
### queue_announcements
This queues announcements for events and CFPs. These are announced three times, evenly spaced between when they were added and when the event starts or the CFP closes.
Run daily.
### send_weekly_email
Does what you think. Creates a weekly email of the latest stuff, and send to everyone in Subscribers.
Run weekly.

2
admin.py Normal file
View file

@ -0,0 +1,2 @@
from django.contrib import admin

0
ausglamr/__init__.py Normal file
View file

16
ausglamr/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for ausglamr project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ausglamr.settings")
application = get_asgi_application()

160
ausglamr/settings.py Normal file
View file

@ -0,0 +1,160 @@
"""
Django settings for ausglamr project.
Generated by 'django-admin startproject' using Django 4.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import os
from pathlib import Path
from environs import Env
# .env
env = Env()
env.read_env()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env("DEBUG")
ALLOWED_HOSTS = [env("DOMAIN")]
CSRF_TRUSTED_ORIGINS = [f'https://{env("DOMAIN")}']
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.postgres.search",
"django.contrib.staticfiles",
"blogs",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "ausglamr.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "ausglamr.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("POSTGRES_DB", "ausglamr"),
"USER": env("POSTGRES_USER", "ausglamr"),
"PASSWORD": env("POSTGRES_PASSWORD", "ausglamr"),
"HOST": env("POSTGRES_HOST", "db"),
"PORT": env.int("PGPORT", 5432),
"TEST": {
"NAME": f"{env('POSTGRES_DB')}_test",
},
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # pylint: disable=line-too-long
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = env("LANGUAGE_CODE")
TIME_ZONE = env("TIME_ZONE")
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
},
}
MEDIA_URL = "media/"
STATIC_URL = "static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static/")
STATICFILES_DIRS = []
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# ENV SETTINGS
DOMAIN = env("DOMAIN")
ADMIN_EMAIL = env("ADMIN_EMAIL") # custom
# django email settings
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL")
EMAIL_HOST = env("EMAIL_HOST")
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_PORT = env("EMAIL_PORT")
EMAIL_USE_SSL = env("EMAIL_USE_SSL")
# mastodon
MASTODON_ACCESS_TOKEN = env("MASTODON_ACCESS_TOKEN")
MASTODON_DOMAIN = env("MASTODON_DOMAIN")

55
ausglamr/urls.py Normal file
View file

@ -0,0 +1,55 @@
"""
URL configuration for ausglamr project.
"""
from django.contrib import admin
from django.urls import path, re_path
from blogs import views
urlpatterns = [
path("admin/", admin.site.urls),
path("", views.HomeFeed.as_view(), name="home"),
re_path(r"^browse/?$", views.Browse.as_view(), name="browse"),
re_path(r"^search/?$", views.Search.as_view(), name="search"),
re_path(r"^help/?$", views.Help.as_view(), name="help"),
path("contribute", views.Contribute.as_view(), name="contribute"),
path("contact", views.Contact.as_view(), name="contact"),
path("blogs", views.Blogs.as_view(), name="blogs"),
path("events", views.Conferences.as_view(), name="events"),
path("cfps", views.CallsForPapers.as_view(), name="cfps"),
path("groups", views.Groups.as_view(), name="groups"),
path("newsletters", views.Newsletters.as_view(), name="newsletters"),
path("register-blog", views.RegisterBlog.as_view(), name="register-blog"),
path(
"submit-blog-registration",
views.ConfirmBlogRegistration.as_view(),
name="submit-blog-registration",
),
path(
"register-event",
views.RegisterConference.as_view(),
name="register-event",
),
path("register-cfp", views.RegisterCallForPapers.as_view(), name="register-cfp"),
path("register-group", views.RegisterGroup.as_view(), name="register-group"),
path(
"register-newsletter",
views.RegisterNewsletter.as_view(),
name="register-newsletter",
),
path("thankyou/<register_type>", views.Thankyou.as_view(), name="thankyou"),
path("subscribe", views.Subscribe.as_view(), name="subscribe"),
path("subscribe-email", views.SubscribeEmail.as_view(), name="subscribe-email"),
path(
"confirm-subscribe-email/<token>/<id>",
views.ConfirmEmail.as_view(),
name="confirm-subscribe-email",
),
path(
"unsubscribe-email/<token>/<id>",
views.UnsubscribeEmail.as_view(),
name="unsubscribe-email",
),
path("feeds/blogs", views.ArticleFeed(), name="article-feed"),
path("feeds/events", views.EventFeed(), name="event-feed"),
]

16
ausglamr/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for ausglamr project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ausglamr.settings")
application = get_wsgi_application()

0
blogs/__init__.py Normal file
View file

216
blogs/admin.py Normal file
View file

@ -0,0 +1,216 @@
"""admin interface customisations"""
# pylint: disable=W0613
from django.conf import settings
from django.contrib import admin
from django.utils import timezone
from . import models
from . import utilities
@admin.action(description="Approve selected")
def approve(modeladmin, request, queryset):
"""approve selected"""
queryset.update(approved=True)
for instance in queryset:
instance.announce()
if hasattr(instance, "event"): # CFP
recipient = instance.event.owner_email
if hasattr(instance, "owner_email"): # overrides above in case needed in future
recipient = instance.owner_email
if recipient:
if hasattr(instance, "name"):
title = instance.name
else:
title = instance.title
subject = f"{title} has been approved on AusGLAMR!"
message = f"<html><body><p>{title} has been approved on <a href='https://{settings.DOMAIN}'>AusGLAMR</a>. Hooray!</p></body></html>"
utilities.send_email(subject, message, recipient)
@admin.action(description="Unapprove selected")
def unapprove(modeladmin, request, queryset):
"""unapprove selected"""
queryset.update(approved=False)
@admin.action(description="Suspend selected blogs")
def suspend(modeladmin, request, queryset):
"""suspend selected blogs"""
queryset.update(suspended=True)
for instance in queryset:
if hasattr(instance, "owner_email"):
if hasattr(instance, "name"):
title = instance.name
else:
title = instance.title
subject = f"⚠️ {title} has been suspended from AusGLAMR!"
message = f"<html><body>\
<p>Your blog {title} has been suspended from AusGLAMR. It may be unsuspended in future once the issue is resolved. If you would like more information, please reply to this email.</p> \
</body></html>"
utilities.send_email(subject, message, instance.owner_email)
@admin.action(description="Unsuspend selected blogs")
def unsuspend(modeladmin, request, queryset):
"""unsuspend selected blogs"""
queryset.update(suspended=False, suspension_lifted=timezone.now())
for instance in queryset:
if hasattr(instance, "owner_email"):
if hasattr(instance, "name"):
title = instance.name
else:
title = instance.title
subject = f"✅ The AusGLAMR suspension on {title} has been lifted"
message = f"<html><body>\
<p>The suspension on your blog {title} has been removed on AusGLAMR. Please note that articles published whilst it was suspended will not be added to AusGLAMR retrospectively. If you would like more information, please reply to this email.</p> \
</body></html>"
utilities.send_email(subject, message, instance.owner_email)
@admin.action(description="Confirm selected subscribers")
def confirm(modeladmin, request, queryset):
"""confirm selected"""
queryset.update(confirmed=True)
@admin.action(description="Unconfirm selected subscribers")
def unconfirm(modeladmin, request, queryset):
"""unconfirm selected"""
queryset.update(confirmed=False)
@admin.action(description="Send confirmation request to selected subscribers")
def send_conf_request(modeladmin, request, queryset):
"""send to selected"""
for instance in queryset:
instance.send_confirmation_email()
@admin.register(models.Blog)
class Blog(admin.ModelAdmin):
"""display settings for blogs"""
list_display = (
"title",
"url",
"author_name",
"approved",
"announced",
"suspended",
"failing",
)
ordering = ["approved", "-suspended", "-failing"]
actions = [approve, unapprove, suspend, unsuspend]
@admin.register(models.Article)
class Article(admin.ModelAdmin):
"""display settings for articles"""
date_hierarchy = "pubdate"
list_display = ("title", "blog_title", "pubdate")
def blog_title(self, obj): # pylint: disable=no-self-use
"""get the title of the parent blog"""
return obj.blog.title
@admin.register(models.Tag)
class Tag(admin.ModelAdmin):
"""display settings for tags"""
list_display = ("name",)
@admin.register(models.Event)
class Event(admin.ModelAdmin):
"""display settings for conferences"""
list_display = (
"name",
"approved",
"announcements",
"category",
"description",
"start_date",
)
ordering = ["approved", "announcements"]
actions = [approve, unapprove]
@admin.register(models.CallForPapers)
class CallForPapers(admin.ModelAdmin):
"""display settings for CFPs"""
list_display = ("name", "event", "approved", "closing_date")
list_select_related = ("event",)
ordering = ["approved", "closing_date"]
actions = [approve, unapprove]
@admin.register(models.Group)
class Group(admin.ModelAdmin):
"""display settings for groups"""
list_display = ("name", "approved", "category", "description")
ordering = ["approved", "name"]
actions = [approve, unapprove]
@admin.register(models.Newsletter)
class Newsletter(admin.ModelAdmin):
"""display settings for newsletters"""
list_display = ("name", "approved", "category", "description")
ordering = ["approved", "name"]
actions = [approve, unapprove]
@admin.register(models.ContentWarning)
class ContentWarning(admin.ModelAdmin):
"""display settings for CWs"""
list_display = (
"match_text",
"display",
)
@admin.register(models.Announcement)
class Announcement(admin.ModelAdmin):
"""display settings for announcements"""
list_display = ("status",)
@admin.register(models.SiteMessage)
class SiteMessage(admin.ModelAdmin):
"""create a message"""
list_display = ("message",)
@admin.register(models.Subscriber)
class Subscriber(admin.ModelAdmin):
"""email subscribers"""
list_display = (
"email",
"confirmed",
)
actions = [confirm]

9
blogs/apps.py Normal file
View file

@ -0,0 +1,9 @@
"""django apps"""
from django.apps import AppConfig
class BlogsConfig(AppConfig):
"""default config class"""
default_auto_field = "django.db.models.BigAutoField"
name = "blogs"

143
blogs/forms.py Normal file
View file

@ -0,0 +1,143 @@
"""forms for use in views"""
from django import forms
from django.utils import timezone
from .models import Blog, CallForPapers, Event, Group, Newsletter, Subscriber
class DateInput(forms.DateInput):
"""make date input a date picker"""
input_type = "date"
class RegisterBlogForm(forms.ModelForm):
"""form for registering a blog"""
class Meta:
"""set fields and model"""
model = Blog
fields = ["url", "category", "activitypub_account_name", "owner_email"]
class ConfirmBlogForm(forms.ModelForm):
"""confirm all details are correct before final blog submission"""
class Meta:
"""set fields and model"""
model = Blog
fields = [
"url",
"feed",
"title",
"author_name",
"description",
"category",
"activitypub_account_name",
"owner_email",
]
class RegisterConferenceForm(forms.ModelForm):
"""form for registering a event"""
class Meta:
"""set fields and model"""
model = Event
fields = [
"name",
"url",
"category",
"description",
"start_date",
"activitypub_account_name",
"owner_email",
]
widgets = {
"start_date": DateInput(),
}
class RegisterCallForPapersForm(forms.ModelForm):
"""form for registering a CallForPapers"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# we don't want every event ever created to be listed
self.fields["event"].queryset = Event.objects.filter(
approved=True, start_date__gte=timezone.now()
).order_by("start_date")
class Meta:
"""set fields and model"""
model = CallForPapers
fields = ["event", "name", "details", "opening_date", "closing_date"]
widgets = {
"opening_date": DateInput(),
"closing_date": DateInput(),
}
class RegisterGroupForm(forms.ModelForm):
"""form for registering a group"""
class Meta:
"""set fields and model"""
model = Group
fields = [
"name",
"category",
"type",
"url",
"registration_url",
"description",
"owner_email",
]
class RegisterNewsletterForm(forms.ModelForm):
"""form for registering a newsletter"""
class Meta:
"""set fields and model"""
model = Newsletter
fields = [
"name",
"author",
"category",
"url",
"description",
"activitypub_account_name",
"owner_email",
]
class ContactForm(forms.Form):
"""form for contacting site admin"""
from_email = forms.EmailField(label="Email", max_length=200)
subject = forms.CharField(label="Subject", max_length=200)
message = forms.CharField(widget=forms.Textarea)
class SubscribeViaMastodon(forms.Form):
"""form for subscribing to Mastodon bot"""
username = forms.CharField(label="Username", max_length=200)
class SubscribeEmailForm(forms.ModelForm):
"""subscribe via email form"""
class Meta:
"""set fields and model"""
model = Subscriber
fields = ["email"]

View file

View file

@ -0,0 +1 @@
""" call these commands using 'python manage.py name_of_command' """

View file

@ -0,0 +1,19 @@
"""announce something"""
from django.core.management.base import BaseCommand
from blogs.models import Announcement
class Command(BaseCommand):
"""the announce command"""
# we could add arguments but we don't really need any
def handle(self, *args, **options):
"""check for pending announcements and announce the latest"""
announcement = Announcement.objects.filter().order_by("queued").first()
if announcement:
announcement.announce()

View file

@ -0,0 +1,118 @@
"""call this from cron to run through all the feeds to find new posts"""
from datetime import datetime, timedelta, timezone
import feedparser
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone as django_timezone
from blogs import models
def date_to_tz_aware(date_tuple):
"""turn a 9-tuple into something usable"""
# we are assuming all dates are UTC which is a bit dodgy but it works
return datetime(*date_tuple[0:7], tzinfo=timezone.utc)
def get_tags(dictionary):
"""parse out tags from blog and upsert as tag instances"""
tags = []
for tag_obj in dictionary:
tag = models.Tag.objects.filter(name=tag_obj.term.lower()).first()
if not tag:
tag = models.Tag.objects.create(name=tag_obj.term.lower())
tags.append(tag)
return tags
class Command(BaseCommand):
"""the check_feeds command"""
# we could add arguments but we don't really need any
def handle(self, *args, **options):
"""check feeds and update database"""
print(f"checking feeds at {django_timezone.localtime(django_timezone.now())}")
blogs = models.Blog.objects.filter(approved=True, suspended=False).all()
for blog in blogs:
try:
data = feedparser.parse(blog.feed)
for article in data.entries:
if not models.Article.objects.filter(
Q(url=article.link) | Q(guid=article.id)
).exists():
if (
blog.suspension_lifted
and blog.suspension_lifted
< date_to_tz_aware(article.updated_parsed)
):
continue # don't ingest posts published during a suspension
tags = get_tags(
getattr(article, "tags", None)
or getattr(article, "categories", [])
)
opt_out = False
# don't include posts with opt out tags
for tag in tags:
if (
len(
{tag.name}
& {
"notglam",
"notglamr",
"notausglamblogs",
"notausglamr",
"notglamblogs",
"#notglam",
}
)
> 0
):
opt_out = True
else:
continue
if not opt_out:
author_name = getattr(article, "author", None) or getattr(
blog, "author", None
)
instance = models.Article.objects.create(
title=article.title,
author_name=author_name,
url=article.link,
description=article.summary,
updateddate=date_to_tz_aware(article.updated_parsed),
blog=blog,
pubdate=date_to_tz_aware(article.published_parsed),
guid=article.id,
)
for tag in tags:
instance.tags.add(tag)
instance.save()
cutoff = django_timezone.now() - timedelta(days=3)
newish = instance.pubdate > cutoff
if newish:
instance.announce()
blog.set_success()
except Exception as e:
blog.set_failing()
print(f"ERROR WITH BLOG {blog.title} - {blog.url}")
print(e)
print(f"completed run at {django_timezone.localtime(django_timezone.now())}")

View file

@ -0,0 +1,49 @@
"""check whether announcements need to be queued"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from blogs import models
class Command(BaseCommand):
"""the command"""
# we could add arguments but we don't really need any
def handle(self, *args, **options):
"""check whether we need to queue announcements and queue them"""
conferences = models.Event.objects.filter(
approved=True,
announcements__lt=3,
start_date__gte=timezone.now(),
)
calls = models.CallForPapers.objects.filter(
announcements__lt=3,
closing_date__gte=timezone.now(),
conference__approved=True,
)
for conf in conferences:
delta = conf.start_date - timezone.now().date()
if (
conf.announcements < 1
or (delta < timedelta(days=7))
or (delta < timedelta(months=3) and conf.announcements < 2)
):
conf.announce()
for cfp in calls:
delta_one = timezone.now().date() - cfp.opening_date
delta_two = cfp.closing_date - timezone.now().date()
if (
cfp.announcements < 1
or (delta_one > delta_two and cfp.announcements < 2)
or (delta_two < timedelta(days=7))
):
cfp.announce()

View file

@ -0,0 +1,213 @@
"""send the weekly email"""
from datetime import timedelta
import random
from django.conf import settings
from django.utils import timezone
from django.core.management.base import BaseCommand
from blogs import models
from blogs.utilities import send_email
from blogs.models.utils import GroupType
class Command(BaseCommand):
"""the announce command"""
# we could add arguments but we don't really need any
def handle(self, *args, **options):
"""find subscribers and send an update"""
subscribers = models.Subscriber.objects.filter(confirmed=True)
print(
f"Sending weekly emails to {len(subscribers)} subscribers at {timezone.now()}"
)
cutoff = timezone.now() - timedelta(days=7)
blogs = models.Blog.objects.filter(approved=True, updateddate__gte=cutoff)
articles = models.Article.objects.filter(pubdate__gte=cutoff)
events = models.Event.objects.filter(approved=True, pub_date__gte=cutoff)
cfps = models.CallForPapers.objects.filter(
conference__approved=True, closing_date__gte=timezone.now().date()
)
newsletters = models.Newsletter.objects.filter(
approved=True, pub_date__gte=cutoff
)
groups = models.Group.objects.filter(approved=True, pub_date__gte=cutoff)
new_blogs = ""
for blog in blogs:
title_string = f"<h4><a href='{blog.url}'>{blog.title}</a></h4>"
author_string = (
f"<p><em>{blog.author_name}</em></p>" if blog.author_name else ""
)
description_string = (
f"<p style='margin-bottom:24px;'>{blog.description}</p>"
)
string_list = [title_string, author_string, description_string]
string = "".join(string_list)
new_blogs = new_blogs + string
if new_blogs != "":
new_blogs = (
"<h3 style='margin-top:20px;'>New Blogs</h3>" + new_blogs + "<hr/>"
)
new_articles = ""
for post in articles:
title_string = f"<h4><a href='{post.url}'>{post.title}</a></h4>"
author_string = (
f"<p><em>{post.author_name}</em></p>" if post.author_name else ""
)
description_string = (
f"<p style='margin-bottom:24px;'>{post.description}</p>"
)
string_list = [title_string, author_string, description_string]
string = "".join(string_list)
new_articles = new_articles + string
if new_articles != "":
new_articles = (
"<h3 style='margin-top:20px;'>New Articles</h3>"
+ new_articles
+ "<hr/>"
)
coming_events = ""
for event in events:
s_date = event.start_date
title_string = f"<h4><a href='{event.url}'>{event.name}</a></h4>"
date_string = (
f"<p><em>{s_date:%a} {s_date.day} {s_date:%B} {s_date:%Y}</em></p>"
)
description_string = (
f"<p style='margin-bottom:24px;'>{event.description}</p>"
)
string_list = [title_string, date_string, description_string]
string = "".join(string_list)
coming_events = coming_events + string
if coming_events != "":
coming_events = (
"<h3 style='margin-top:20px;'>Upcoming Events</h3>"
+ coming_events
+ "<hr/>"
)
open_cfps = ""
for instance in cfps:
c_date = instance.closing_date
title_string = (
f"<h4><a href='{instance.event.url}'>{instance.name}</a></h4>"
)
dates_string = f"<p><strong>Closes:</strong><em>{c_date:%a} {c_date.day} {c_date:%B}</em></p>"
description_string = (
f"<p style='margin-bottom:24px;'>{instance.details}</p>"
)
string_list = [title_string, dates_string, description_string]
string = "".join(string_list)
open_cfps = open_cfps + string
if open_cfps != "":
open_cfps = (
"<h3 style='margin-top:20px;'>Open Calls</h3>" + open_cfps + "<hr/>"
)
new_newsletters = ""
for instance in newsletters:
title_string = f"<h4><a href='{instance.url}'>{instance.name}</a></h4>"
author_string = (
f"<p><em>{instance.author}</em></p>" if instance.author else ""
)
description_string = (
f"<p style='margin-bottom:24px;'>{instance.description}</p>"
)
string_list = [title_string, author_string, description_string]
string = "".join(string_list)
new_newsletters = new_newsletters + string
if new_newsletters != "":
new_newsletters = (
"<h3 style='margin-top:20px;'>New Newsletters</h3>"
+ new_newsletters
+ "<hr/>"
)
new_groups = ""
for instance in groups:
group_type = GroupType(instance.type).label
title_string = f"<h4><a href='{instance.url}'>{instance.name}</a></h4>"
register_string = f"<p><em><a href='{instance.registration_url}'>Register</a> to join this {group_type}</em></p>"
description_string = (
f"<p style='margin-bottom:24px;'>{instance.description}</p>"
)
string_list = [title_string, register_string, description_string]
string = "".join(string_list)
new_groups = new_groups + string
if new_groups != "":
new_groups = (
"<h3 style='margin-top:20px;'>New Groups</h3>" + new_groups + "<hr/>"
)
# Now let's put it all together...
dt = timezone.now()
choices = [
"🍓",
"🍒",
"🍎",
"🍊",
"🍍",
"🍋",
"🍉",
"🥝",
"🥦",
"🥒",
"🥕",
"🍏",
"🍅",
"🥬",
"🫐",
"🍐",
"🥗",
"☕️",
"🚚",
"📬",
"🍣",
]
emoji = random.choice(choices)
subject = f"{emoji} Fresh Aus GLAMR updates for the week of {dt.day} {dt:%B} {dt.year}"
sections = [
new_articles,
new_blogs,
new_newsletters,
new_groups,
open_cfps,
coming_events,
]
body = "".join(sections)
for subscriber in subscribers:
opt_out = f"https://{settings.DOMAIN}/unsubscribe-email/{subscriber.token}/{subscriber.id}"
start = "<html><body>"
footer = f"<div style='padding: 20px; width: 100vw; background-color:#eee; margin-top: 100px;text-align:center;'><em><p>This email was sent to <strong>{subscriber.email}</strong> because you subscribed to email updates from <a href='https://{settings.DOMAIN}'>Aus GLAMR</a>.</p><p>You can <a href='{opt_out}'>unsubscribe</a> at any time.</p></em></div>"
end = "</body></html>"
parts = [start, body, footer, end]
message = "".join(parts)
send_email(subject, message, subscriber.email)
print(f"Weekly emails completed {timezone.now()}")

View file

@ -0,0 +1,369 @@
# Generated by Django 4.2.7 on 2024-01-03 03:35
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Announcement",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("status", models.TextField()),
("summary", models.TextField(null=True)),
("queued", models.DateTimeField(default=django.utils.timezone.now)),
],
),
migrations.CreateModel(
name="Blog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=2000)),
("author_name", models.CharField(max_length=1000, null=True)),
("url", models.URLField(max_length=2000, unique=True)),
("description", models.TextField(blank=True, null=True)),
("updateddate", models.DateTimeField()),
("feed", models.URLField(max_length=2000)),
(
"category",
models.CharField(
choices=[
("GAL", "Galleries"),
("LIB", "Libraries"),
("ARC", "Archives"),
("MUS", "Museums"),
("REC", "Records"),
("DH", "Digital Humanities"),
("GLAM", "GLAMR"),
],
max_length=4,
),
),
("approved", models.BooleanField(default=False)),
("announced", models.BooleanField(default=False)),
("failing", models.BooleanField(blank=True, default=False, null=True)),
(
"suspended",
models.BooleanField(blank=True, default=False, null=True),
),
("suspension_lifted", models.DateTimeField(blank=True, null=True)),
(
"activitypub_account_name",
models.CharField(blank=True, max_length=200, null=True),
),
(
"owner_email",
models.EmailField(blank=True, max_length=254, null=True),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ContentWarning",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("match_text", models.CharField(max_length=999, null=True)),
("display", models.CharField(max_length=999, null=True)),
],
),
migrations.CreateModel(
name="Event",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=999)),
(
"category",
models.CharField(
choices=[
("GAL", "Galleries"),
("LIB", "Libraries"),
("ARC", "Archives"),
("MUS", "Museums"),
("REC", "Records"),
("DH", "Digital Humanities"),
("GLAM", "GLAMR"),
],
max_length=4,
),
),
("url", models.URLField(max_length=400, unique=True)),
("description", models.TextField(blank=True, null=True)),
("pub_date", models.DateTimeField()),
("start_date", models.DateField()),
(
"announcements",
models.IntegerField(blank=True, default=0, null=True),
),
(
"activitypub_account_name",
models.CharField(blank=True, max_length=200, null=True),
),
(
"owner_email",
models.EmailField(blank=True, max_length=254, null=True),
),
("approved", models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name="Group",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=999)),
(
"category",
models.CharField(
choices=[
("GAL", "Galleries"),
("LIB", "Libraries"),
("ARC", "Archives"),
("MUS", "Museums"),
("REC", "Records"),
("DH", "Digital Humanities"),
("GLAM", "GLAMR"),
],
max_length=4,
),
),
(
"type",
models.CharField(
choices=[
("DISC", "Discord server"),
("DCRS", "Discourse community"),
("EML", "email list"),
("GOOG", "Google group"),
("KBIN", "KBin server"),
("LEMM", "Lemmy server"),
("MAS", "Mastodon server"),
("RED", "subreddit"),
("SLAC", "Slack channel"),
("ZLIP", "Zulip server"),
("OTHR", "group"),
],
max_length=4,
),
),
("url", models.URLField(max_length=400, unique=True)),
("registration_url", models.URLField(max_length=400, unique=True)),
("description", models.TextField(blank=True, null=True)),
(
"owner_email",
models.EmailField(blank=True, max_length=254, null=True),
),
("announced", models.BooleanField(default=False)),
("approved", models.BooleanField(default=False)),
("pub_date", models.DateTimeField(default=None, null=True)),
],
),
migrations.CreateModel(
name="Newsletter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=999)),
("author", models.CharField(max_length=999)),
(
"category",
models.CharField(
choices=[
("GAL", "Galleries"),
("LIB", "Libraries"),
("ARC", "Archives"),
("MUS", "Museums"),
("REC", "Records"),
("DH", "Digital Humanities"),
("GLAM", "GLAMR"),
],
max_length=4,
),
),
("url", models.URLField(max_length=400, unique=True)),
("description", models.TextField(blank=True, null=True)),
(
"activitypub_account_name",
models.CharField(blank=True, max_length=200, null=True),
),
(
"owner_email",
models.EmailField(blank=True, max_length=254, null=True),
),
("announced", models.BooleanField(default=False)),
("approved", models.BooleanField(default=False)),
("pub_date", models.DateTimeField(default=None, null=True)),
],
),
migrations.CreateModel(
name="SiteMessage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("message", models.TextField(max_length=999)),
],
),
migrations.CreateModel(
name="Subscriber",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, null=True, unique=True
),
),
("confirmed", models.BooleanField(default=False, editable=False)),
("token", models.UUIDField(default=uuid.uuid4, editable=False)),
],
),
migrations.CreateModel(
name="Tag",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, unique=True)),
],
),
migrations.CreateModel(
name="CallForPapers",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=999)),
("details", models.TextField(blank=True, null=True)),
("pub_date", models.DateTimeField(default=None, null=True)),
("opening_date", models.DateField()),
("closing_date", models.DateField()),
("announcements", models.IntegerField(default=0, null=True)),
("approved", models.BooleanField(default=False)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cfp",
to="blogs.event",
),
),
],
),
migrations.CreateModel(
name="Article",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=2000)),
("author_name", models.CharField(max_length=1000, null=True)),
("url", models.URLField(max_length=2000, unique=True)),
("description", models.TextField(blank=True, null=True)),
("updateddate", models.DateTimeField()),
("pubdate", models.DateTimeField()),
("guid", models.CharField(max_length=2000)),
(
"blog",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="articles",
to="blogs.blog",
),
),
(
"tags",
models.ManyToManyField(related_name="articles", to="blogs.tag"),
),
],
options={
"abstract": False,
},
),
]

View file

8
blogs/models/__init__.py Normal file
View file

@ -0,0 +1,8 @@
"""blogs app models"""
from .blog import Article, Blog, Tag
from .event import Event, CallForPapers
from .group import Group
from .newsletter import Newsletter
from .utils import Announcement, Category, ContentWarning, SiteMessage
from .subscriber import Subscriber

128
blogs/models/blog.py Normal file
View file

@ -0,0 +1,128 @@
"""blog models"""
from django.db import models
from django.utils import timezone
from .utils import Announcement, Category, ContentWarning
class BlogData(models.Model):
"""Base bloggy data"""
title = models.CharField(max_length=2000)
author_name = models.CharField(max_length=1000, null=True)
url = models.URLField(max_length=2000, unique=True)
description = models.TextField(null=True, blank=True)
updateddate = models.DateTimeField()
class Meta:
"""This is an abstract model for common data"""
abstract = True
def __str__(self):
"""display for admin dropdowns"""
return self.title
def get_absolute_url(self):
"""override"""
return self.url
def save(self, *args, **kwargs):
if not self.updateddate:
self.updateddate = timezone.now()
super().save(*args, **kwargs)
class Blog(BlogData):
"""A blog"""
feed = models.URLField(max_length=2000)
category = models.CharField(choices=Category.choices, max_length=4)
approved = models.BooleanField(default=False)
announced = models.BooleanField(default=False)
failing = models.BooleanField(default=False, blank=True, null=True)
suspended = models.BooleanField(default=False, blank=True, null=True)
suspension_lifted = models.DateTimeField(blank=True, null=True)
activitypub_account_name = models.CharField(max_length=200, blank=True, null=True)
owner_email = models.EmailField(blank=True, null=True)
def announce(self):
"""queue announcement"""
if self.activitypub_account_name:
author = f" by {self.activitypub_account_name}"
elif self.author_name:
author = f" by {self.author_name}"
else:
author = ""
category = Category(self.category).label
status = f"{self.title}{author} has been added to Aus GLAMR! \
\n\nIt's about {category}\n\n{self.url}"
Announcement.objects.create(status=status)
self.announced = True
super().save()
def set_failing(self):
"""set the blog feed as failing"""
self.failing = True
super().save()
def set_success(self):
"""set failing to false"""
self.failing = False
super().save()
class Article(BlogData):
"""A blog post"""
blog = models.ForeignKey(Blog, on_delete=models.CASCADE, related_name="articles")
pubdate = models.DateTimeField()
guid = models.CharField(max_length=2000)
tags = models.ManyToManyField("Tag", related_name="articles")
# pylint: disable=undefined-variable
def announce(self):
"""queue a blog post announcement"""
summary = []
warnings = ContentWarning.objects.all()
for warning in warnings:
for text in [self.title, self.description]:
label = warning.is_in(text)
if label:
summmary.append(label)
for tag in self.tags: # pylint: disable=E1133
label = warning.is_in(tag.name)
if label:
summmary.append(label)
summary_text = ", ".join(summary) if len(summary) > 0 else None
author = self.blog.activitypub_account_name or self.author_name
if self.blog.activitypub_account_name:
author = f"{self.blog.activitypub_account_name} "
elif self.author_name:
author = f"{self.author_name} "
else:
author = ""
status = f"{self.title} ({author}on {self.blog.title})\n\n{self.url}"
Announcement.objects.create(status=status, summary=summary_text)
class Tag(models.Model):
"""An article tag"""
name = models.CharField(max_length=100, unique=True)
def __str__(self):
"""display for admin dropdowns"""
return self.name

85
blogs/models/event.py Normal file
View file

@ -0,0 +1,85 @@
"""event models"""
from django.db import models
from django.utils import timezone
from .utils import Announcement, Category
class Event(models.Model):
"""a event"""
name = models.CharField(max_length=999)
category = models.CharField(choices=Category.choices, max_length=4)
url = models.URLField(max_length=400, unique=True)
description = models.TextField(null=True, blank=True)
pub_date = models.DateTimeField() # for RSS feed
start_date = models.DateField()
announcements = models.IntegerField(null=True, blank=True, default=0)
activitypub_account_name = models.CharField(max_length=200, blank=True, null=True)
owner_email = models.EmailField(blank=True, null=True)
approved = models.BooleanField(default=False)
def save(self, *args, **kwargs):
if not self.pub_date:
self.pub_date = timezone.now()
super().save(*args, **kwargs)
def __str__(self):
"""display for admin dropdowns"""
return self.name
def get_absolute_url(self):
"""override for rss feed"""
return self.url
def announce(self):
"""announce a event"""
date = self.start_date.strftime("%a %d %b %Y")
category = Category(self.category).label
name = self.name
if self.activitypub_account_name:
name = f"{self.name} ({self.activitypub_account_name})"
status = (
f"{name} is a event about {category}, starting on {date}!\n\n{self.url}"
)
Announcement.objects.create(status=status)
self.announcements = self.announcements + 1
super().save()
class CallForPapers(models.Model):
"""a event call for papers/presentations"""
name = models.CharField(
max_length=999
) # "Call for papers", "call for participation" etc
details = models.TextField(null=True, blank=True)
pub_date = models.DateTimeField(null=True, default=None)
opening_date = models.DateField()
closing_date = models.DateField()
announcements = models.IntegerField(null=True, default=0)
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="cfp")
approved = models.BooleanField(default=False)
def announce(self):
"""create a call for papers announcement"""
opening_date = self.opening_date.strftime("%a %d %b %Y")
closing_date = self.closing_date.strftime("%a %d %b %Y")
status = f"{self.event.name} {self.name } is open from {opening_date}, closing on {closing_date}!\n\nMore info at {self.event.url}"
if self.event.approved:
Announcement.objects.create(status=status)
self.announcements = self.announcements + 1
super().save()
def save(self, *args, **kwargs):
"""save a CFP with pub_date"""
if not self.pub_date:
self.pub_date = timezone.now()
super().save(*args, **kwargs)

37
blogs/models/group.py Normal file
View file

@ -0,0 +1,37 @@
"""group models"""
from django.db import models
from django.utils import timezone
from .utils import Announcement, Category, GroupType
class Group(models.Model):
"""a group on email, discord, slack etc"""
name = models.CharField(max_length=999)
category = models.CharField(choices=Category.choices, max_length=4)
type = models.CharField(choices=GroupType.choices, max_length=4)
url = models.URLField(max_length=400, unique=True)
registration_url = models.URLField(max_length=400, unique=True)
description = models.TextField(null=True, blank=True)
owner_email = models.EmailField(blank=True, null=True)
announced = models.BooleanField(default=False)
approved = models.BooleanField(default=False)
pub_date = models.DateTimeField(null=True, default=None)
def announce(self):
"""create a group announcement"""
category = Category(self.category).label
type = GroupType(self.type).label
status = f"{self.name} is a {type} about {category}!\n\nJoin them: {self.registration_url}"
Announcement.objects.create(status=status)
self.announced = True
super().save()
def save(self, *args, **kwargs):
if not self.pub_date:
self.pub_date = timezone.now()
super().save(*args, **kwargs)

View file

@ -0,0 +1,41 @@
"""newsletter models"""
from django.db import models
from django.utils import timezone
from .utils import Announcement, Category
class Newsletter(models.Model):
"""a newsletter"""
name = models.CharField(max_length=999)
author = models.CharField(max_length=999)
category = models.CharField(choices=Category.choices, max_length=4)
url = models.URLField(max_length=400, unique=True)
description = models.TextField(null=True, blank=True)
activitypub_account_name = models.CharField(max_length=200, blank=True, null=True)
owner_email = models.EmailField(blank=True, null=True)
announced = models.BooleanField(default=False)
approved = models.BooleanField(default=False)
pub_date = models.DateTimeField(null=True, default=None)
def announce(self):
"""create a event announcement"""
category = Category(self.category).label
name = self.name
if self.activitypub_account_name:
name = f"{self.name} ({self.activitypub_account_name})"
status = f"{name} is a newsletter about {category} from {self.author}. Check it out:\n\n{self.url}"
Announcement.objects.create(status=status)
self.announced = True
super().save()
def save(self, *args, **kwargs):
if not self.pub_date:
self.pub_date = timezone.now()
super().save(*args, **kwargs)

View file

@ -0,0 +1,41 @@
"""email subscriptions"""
import uuid
from django.conf import settings
from django.db import models
from blogs.utilities import send_email
class Subscriber(models.Model):
"""a person who wants to receive weekly emails"""
email = models.EmailField(blank=True, null=True, unique=True)
confirmed = models.BooleanField(default=False, editable=False)
token = models.UUIDField(default=uuid.uuid4, editable=False)
def save(self, *args, **kwargs):
"""always reset the token on save"""
self.token = uuid.uuid4()
super().save(*args, **kwargs)
def send_confirmation_email(self):
"""send an email requesting confirmation"""
subject = "Please confirm your email address"
recipient = self.email
url = f"{settings.DOMAIN}/confirm-subscribe-email/{self.token}/{self.id}"
opt_out = f"{settings.DOMAIN}/unsubscribe-email/{self.token}/{self.id}"
start = "<html><body>"
body = f"<p>Please <a href='{url}'>confirm your email address</a> to receive weekly updates.</p>"
footer = (
f"<p><em>You can <a href='{opt_out}'>unsubscribe</a> at any time.</em></p>"
)
end = "</body></html>"
parts = [start, body, footer, end]
message = "".join(parts)
send_email(subject, message, recipient)

84
blogs/models/utils.py Normal file
View file

@ -0,0 +1,84 @@
"""utility models for use in other models"""
import requests
from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class Category(models.TextChoices):
"""what GLAMR are you"""
GALLERIES = "GAL", _("Galleries")
LIBRARIES = "LIB", _("Libraries")
ARCHIVES = "ARC", _("Archives")
MUSEUMS = "MUS", _("Museums")
RECORDS = "REC", _("Records")
DIGITAL_HUMANITIES = "DH", _("Digital Humanities")
GLAM = "GLAM", _("GLAMR")
class GroupType(models.TextChoices):
"""what GLAMR are you"""
DISCORD = "DISC", _("Discord server")
DISCOURSE = "DCRS", _("Discourse community")
EMAIL = "EML", _("email list")
GOOGLE = "GOOG", _("Google group")
KBIN = "KBIN", _("KBin server")
LEMMY = "LEMM", _("Lemmy server")
MASTODON = "MAS", _("Mastodon server")
REDDIT = "RED", _("subreddit")
SLACK = "SLAC", _("Slack channel")
ZULIP = "ZLIP", _("Zulip server")
OTHER = "OTHR", _("group")
class Announcement(models.Model):
"""an announcement on Mastodon"""
status = models.TextField()
summary = models.TextField(null=True)
queued = models.DateTimeField(default=timezone.now)
def save(self, *args, **kwargs):
if not self.queued:
self.queued = timezone.now()
super().save(*args, **kwargs)
def announce(self):
"""tell the world about it"""
key = settings.MASTODON_ACCESS_TOKEN
headers = {"Authorization": f"Bearer {key}"}
params = {"status": self.status}
if self.summary:
params["spoiler_text"] = self.summary
url = f"{settings.MASTODON_DOMAIN}/api/v1/statuses"
r = requests.post(url, data=params, headers=headers, timeout=(4, 13))
if r.status_code == 200:
self.delete()
class ContentWarning(models.Model):
"""content warnings"""
match_text = models.CharField(max_length=999, null=True)
display = models.CharField(max_length=999, null=True)
def is_in(self, text):
"""check some text and return a CW if needed"""
warning = None
if self.match_text in text.lower():
warning = self.display
return warning
class SiteMessage(models.Model):
"""A message to be displayed somewhere"""
message = models.TextField(max_length=999)

View file

@ -0,0 +1,54 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Processing...</div>
</div>
{% if error %}
<div class="alert failure">
{{ error }}
</div>
{% endif %}
<form action="{% url 'submit-blog-registration' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
<input hidden type="text" name="register_type" value="blog">
<label for="{{ form.title.id_for_label }}">Title:</label>
<input type="text" name="title" id="title" value="{{ blog_info.title }}" class="u-full-width" required="">
<label for="{{ form.author_name.id_for_label }}">Author:</label>
<input type="text" name="author_name" id="author_name" value="{{ blog_info.author_name }}" class="u-full-width" required="">
<label for="{{ form.description.id_for_label }}">Description:</label>
<textarea name="description" id="description" class="u-full-width">{{ blog_info.description }}</textarea>
{{ form.url.errors }}
<label for="{{ form.url.id_for_label }}">URL:</label>
{{ form.url }}
<label for="{{ form.feed.id_for_label }}">Feed:</label>
<input type="text" name="feed" id="feed" value="{{ blog_info.feed }}" class="u-full-width" required="" placeholder="https://example.com/rss.xml">
{{ form.category.errors }}
<label for="{{ form.category.id_for_label }}">Category:</label>
{{ form.category }}
{{ form.activitypub_account_name.errors }}
<label for="{{ form.activitypub_account_name.id_for_label }}">Activitypub account name:</label>
{{ form.activitypub_account_name }}
{{ form.owner_email.errors }}
<label for="{{ form.owner_email.id_for_label }}">Owner email:</label>
{{ form.owner_email }}
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Finding feed data...</div>
</div>
<form action="{% url 'register-blog' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{{ form }}
<input id="submit" class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "layout.html" %}
{% block content %}
<form action="{% url 'submit-blog-registration' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{{ form }}
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block content %}
<div class="listing l-three header">
<div>Title</div>
<div>Author</div>
<div>Category</div>
</div>
<hr/>
{% for blog in blogs %}
<div class="listing l-three">
<div><a href="{{blog.url}}">{{blog.title}}</a></div>
<div>{{blog.author_name}}</div>
<div><div class="end badge badge_{{blog.category}}">{{blog.category_name}}</div></div>
</div>
<hr/>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "layout.html" %}
{% block content %}
<div class="listing l-three header">
<span>Details</span>
<span>Event</span>
<span>Closes</span>
</div>
<hr/>
{% for cfp in cfps %}
<div class="listing l-three">
<div>
<p><strong>{{cfp.name}}</strong></p>
<p>{{cfp.details}}</p>
</div>
<span><a href="{{cfp.event.url}}">{{cfp.event.name}}</a></span>
<span class="closing-date">{{cfp.closing_date}}</span>
</div>
<hr/>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends "layout.html" %}
{% block content %}
<div class="listing l-four header">
<span>Name</span>
<span>Category</span>
<span>Start Date</span>
<span>Description</span>
</div>
<hr/>
{% for con in cons %}
<div>{{con.call_for_papers.closing_date}}</div>
<div class="listing l-four">
<span><a href="{{con.url}}">{{con.name}}</a></span>
<span><span class="badge badge_{{con.category}}">{{con.category_name}}</span></span>
<span class="start-date">{{con.start_date}}</span>
<div>
<div>{{con.description}}</div>
{% if con.call_for_papers %}
<div><em><a href="{% url 'cfps' %}">{{ con.call_for_papers }}</a></em></div>
{% endif %}
</div>
</div>
<hr/>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "layout.html" %}
{% block content %}
<div class="listing l-four header">
<span>Name</span>
<span>Category</span>
<span>Description</span>
<span>Registration Link</span>
</div>
<hr/>
{% for group in groups %}
<div class="listing l-four">
<span><a href="{{group.url}}">{{group.name}}</a></span>
<span><span class="badge badge_{{group.category}}">{{group.category_name}}</span></span>
<span>{{group.description}}</span>
<span><a href="{{group.registration_url}}">Join the {{group.reg_type}}</a></span>
</div>
<hr/>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "layout.html" %}
{% block content %}
<div class="listing l-three header">
<span>Title</span>
<span>Author</span>
<span>Category</span>
</div>
<hr/>
{% for pub in news %}
<div class="listing l-three">
<span><a href="{{pub.url}}">{{pub.name}}</a></span>
<span>{{pub.author}}</span>
<span><span class="badge badge_{{pub.category}}">{{pub.category_name}}</span></span>
</div>
<hr/>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,63 @@
{% extends "layout.html" %}
{% block content %}
{% if items %}
{% for item in items %}
<div class="card">
<div class="card-title">
<a href="{{ item.url }}"><h3>{% if item.title %} {{ item.title }} {% else %} {{item.name}} {% endif %}</h3></a>
<p class="meta">
{% if item.author_name or item.author %}
<span class="author_name">{% if item.author_name%} {{ item.author_name }} {% else %} {{ item.author_name }} {% endif %}</span> |
{% endif %}
{% if item.blog %}
<span class="blog_title">{{ item.blog.title }}</span>
{% elif item.description %}
<p class="">{{ item.description }}</p>
{% elif item.details %}
<p class="">{{ item.details }}</p>
{% endif %}
</p>
{% if item.tags %}
{% for tag in item.tags.all %}
<a href="{% url 'browse' %}?q={{ tag.name|urlencode }}"><span class="badge">{{ tag.name }}</span></a>
{% endfor %}
{% elif item.category_name %}
<span class="badge badge_{{item.category}}">{{item.category_name}}</span>
{% endif %}
</div>
</div>
{% endfor %}
<section class=" pagination row">
<span class="four columns">
{% if items.has_previous %}
<a href="?q={{ query }}&page=1">&laquo; first</a> |
<a href="?q={{ query }}&page={{ items.previous_page_number }}">previous</a>
{% else %}
<span class="inactive">&laquo; first | previous</span>
{% endif %}
</span>
<span class="current four columns">
Page {{ items.number }} of {{ items.paginator.num_pages }}
</span>
<span class="current four columns">
{% if items.has_next %}
<a href="?q={{ query }}&page={{ items.next_page_number }}">next</a> |
<a href="?q={{ query }}&page={{ items.paginator.num_pages }}">last &raquo;</a>
{% else %}
<span class="inactive">next | last &raquo;</span>
{% endif %}
</span>
</section>
{% elif query %}
<p><em>No items found for "{{ query }}"</em></p>
{% endif %}
<section class="trending">
<h4>Top tags</h4>
{% for tag in trending %}
<a href="{% url 'browse' %}?q={{ tag.name|urlencode }}"><span class="badge">{{ tag.name }}</span></a>
{% endfor %}
</section>
{% endblock %}

View file

@ -0,0 +1,5 @@
{% extends "layout.html" %}
{% block content %}
<p>Your email address has been confirmed! You can opt-out at any time by clicking the link at the bottom of the weekly emails.</p>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Sending...</div>
</div>
<form action="{% url 'contact' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
<label for="{{ form.from_email.id_for_label }}">Your email address:</label>
{% if errors %} {{ form.from_email.errors }} {% endif %}
{{form.from_email}}
<label for="{{ form.subject.id_for_label }}">Subject:</label>
{% if errors %} {{ form.subject.errors }} {% endif %}
{{form.subject}}
{% if errors %} {{ form.message.errors }} {% endif %}
<label for="{{ form.message.id_for_label }}">Message:</label>
<textarea name="message" id="message" class="u-full-width"></textarea>
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "layout.html" %}
<main class="container pop-boxes">
{% block intro %}{% endblock %}
{% block title %}
<h2 class="page-title">{{ title }}</h2>
{% endblock %}
<section>
{% block content %}
<section>
<p>You can contribute to <em>Aus GLAMR</em> by registering a blog, newsletter, event, or discussion group. Creators or content should be Australasian-based.</p>
</section>
<section class="contribute">
<section class="pop">
<h2>Register a blog</h2>
<p>We have a capacious definition of "blog". If it's a website that often or sometimes has Strong GLAMR Themes, with an RSS or Atom feed, you can register it here.</p>
<a class="button" href="{% url 'register-blog' %}">Register blog</a>
</section>
<section class="pop">
<h2>Register a group</h2>
<p>A "group" could be an email list, a subreddit, Mastodon server, etc. If it's a many-to-many electronic medium, it's probably a discussion group.</p>
<a class="button" href="{% url 'register-group' %}">Register group</a>
</section>
<section class="pop">
<h2>Register an event</h2>
<p>Conference, convention, seminar, workshop, talk, meet-up...
</br>Whatever you call it, you know what we mean.</p>
<a class="button" href="{% url 'register-event' %}">Register event</a>
</section>
<section class="pop">
<h2>Register a newsletter</h2>
<p>Whether it uses Ghost, Write.as, Mailchimp or something else, if it's an email newsletter with some kind of GLAMR focus you can register it here.</p>
<a class="button" href="{% url 'register-newsletter' %}">Register newsletter</a>
</section>
<section class="pop">
<h2>Register or update a Call for Papers</h2>
<p>When you register your event you can also register a "call for papers", "call for proposals" or whatever it's called for your event. Forgot? Extended or re-opened the call? Use this form.</p>
<a class="button" href="{% url 'register-cfp' %}">Register CFP</a>
</section>
</section>
{% endblock %}
</section>

View file

@ -0,0 +1,66 @@
{% extends "layout.html" %}
{% block intro %}
{% if conf_name %}
<div class="alert success">
{{ conf_name }} has been registered!
</div>
{% endif %}
{% endblock %}
{% block title %}
{% if conf_name %}
<h2>Register a Call for Papers for {{ conf_name }}</h2>
{% else %}
<h2>Register a Call for Papers</h2>
{% endif %}
{% endblock %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Processing...</div>
</div>
<form action="{% url 'register-cfp' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{% if conf_name %}
<input hidden type="text" name="event" value="{{ form.event.value }}">
{% else %}
<label for="{{ form.event.id_for_label }}">Event:</label>
{% if errors %} {{ form.event.errors }} {% endif %}
{{ form.event }}
{% endif %}
<label for="{{ form.name.id_for_label }}">Name:</label>
{% if errors %} {{ form.name.errors }} {% endif %}
<p class="hint">e.g. "Call for Papers", "Request for submissions" etc</p>
<input type="text" name="name" id="name" class="u-full-width" required="">
{% if errors %} {{ form.details.errors }} {% endif %}
<label for="{{ form.details.id_for_label }}">Details:</label>
<textarea name="details" id="details" class="u-full-width"></textarea>
<div class="row">
<span class="columns four">
{% if errors %} {{ form.opening_date.errors }} {% endif %}
<label for="{{ form.opening_date.id_for_label }}">opening_date:</label>
{% if errors %} {{ form.opening_date.errors }} {% endif %}
{{ form.opening_date }}
</span>
<span class="columns four">
<label for="{{ form.closing_date.id_for_label }}">closing_date:</label>
{% if errors %} {{ form.closing_date.errors }} {% endif %}
{{ form.closing_date }}
</span>
</div>
<input class="button-primary u-pull-right" type="submit" value="Register!">
<a href="/"><button class="button u-pull-right button-first" type="button">No thanks</button></a>
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Processing...</div>
</div>
<form action="{% url 'register-event' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{{ form }}
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

49
blogs/templates/help.html Normal file
View file

@ -0,0 +1,49 @@
{% extends "layout.html" %}
{% block content %}
<section class="help">
{% with 'contributing' as anchor %}
{% url 'help' as the_url %}
<h4 id="{{anchor}}">Adding something to the site</h4><a href="{{ the_url }}/#{{anchor}}">🔗</a>
<p>If you want to add a blog, event, group, or newsletter, head to <a href="/contribute">Contribute</a>.</p>
{% endwith %}
{% with 'updating' as anchor %}
{% url 'help' as the_url %}
<h4 id="{{anchor}}">Updating or removing a listing</h4><a href="{{ the_url }}/#{{anchor}}">🔗</a>
<p>Made a mistake or have more information to add? Blog got hacked? Changed careers? Just fill in the <a href="/contact">contact form</a>.</p>
{% endwith %}
{% with 'opting-out' as anchor %}
{% url 'help' as the_url %}
<h4 id="{{anchor}}">Opting out for a blog post</h4><a href="{{ the_url }}/#{{anchor}}">🔗</a>
<p>If you have registered your blog but want to opt-out of being indexed and announced for an individual post, you can add any of the following as tags using your blog publishing software, and your post will be ignored.</p>
<ul>
<li>notglam</li>
<li>notglamr</li>
<li>notausglamr</li>
<li>notausglamblogs</li>
</ul>
{% endwith %}
{% with 'aus-glam-blogs' as anchor %}
{% url 'help' as the_url %}
<h4 id="{{anchor}}">What happened to Aus GLAM Blogs?</h4><a href="{{ the_url }}/#{{anchor}}">🔗</a>
<p><em>Aus GLAM Blogs</em> was focussed on blogs specifically. <em>Aus GLAMR</em> is the successor, now including more GLAMR content. All the blog articles from the original site have been migrated and will be inluded in your search results.</p>
{% endwith %}
{% with 'pocket' as anchor %}
{% url 'help' as the_url %}
<h4 id="{{anchor}}">What happened to the Pocket integration?</h4><a href="{{ the_url }}/#{{anchor}}">🔗</a>
<p>The integration with Pocket from Aus GLAM Blogs is no longer available. Please consider using the RSS feed or email subscription instead.</p>
{% endwith %}
{% with 'activitypub' as anchor %}
{% url 'help' as the_url %}
<h4 id="{{anchor}}">Activitypub account name!!???</h4><a href="{{ the_url }}/#{{anchor}}">🔗</a>
<p>ActivityPub is the protocol used by Mastodon, BlueSky, BookWyrm, Threads, and other "fediverse" social media. If you include an account name, it will be mentioned when your registered thing is announced by the AusGLAMR Mastodon bot. If you'd like to join the "fediverse", try <a href="https://ausglam.space">Aus GLAM Space</a>.</p>
{% endwith %}
</section>
{% endblock %}

View file

@ -0,0 +1,36 @@
{% extends "layout.html" %}
{% block intro %}
<section>
<p><em>Aus GLAMR</em> is the place to find out what's happening in the Australasian cultural memory professions. Whether you work in galleries, libraries, archives, museums, or records, you'll find the latest blog posts, conferences and events, newsletters, and discussion groups here!</p>
<p>Browse one of the lists, <a href="{% url 'search' %}">search by keywords</a>, or <a href="{% url 'contribute' %}">contribute by registering</a> your blog, event, group or newsletter.</p>
<p>You can stay up to date by following the Mastodon bot or subscribing to email updates or to one of the RSS feeds - check out the <a href="{% url 'subscribe' %}">subscribe page</a>.</p>
<p>Please note that whilst off-topic or egregiously offensive content may be refused or removed, inclusion on this site does not imply endorsement by Hugh or newCardigan of the content of any particular listed publication or event.</p>
</section>
{% endblock %}
{% block content %}
{% for article in latest %}
<div class="card">
<div class="card-title">
<a href="{{ article.url }}"><h3>{{ article.title }}</h3></a>
<p class="meta">
{% if article.author_name %}
<span class="author_name">{{ article.author_name }}</span> |
{% endif %}
<span class="blog_title">{{ article.blog.title }}</span>
</p>
{% for tag in article.tags.all %}
<a href="{% url 'browse' %}?q={{ tag.name|urlencode }}"><span class="badge">{{ tag.name }}</span></a>
{% endfor %}
</div>
{% if article.description %}
<div class="card-body">
{% autoescape off %}
<p>{{ article.description|truncatewords_html:60 }}</p>
{% endautoescape %}
</div>
{% endif %}
</div>
{% endfor %}
{% endblock %}

View file

@ -0,0 +1,65 @@
{% load static %}
{% load glamr_tags %}
<!DOCTYPE html>
<html lang="en-AU">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }}</title>
<link rel="stylesheet" href="{% static 'css/normalize.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'css/skeleton.css' %}" type="text/css" />
<link rel="stylesheet" href="{% static 'css/custom.css' %}" type="text/css" />
</head>
<body>
<header>
<div class="container">
<a href="/"><h1>Aus GLAMR</h1></a>
<a href="{% url 'blogs' %}"><span class="menu-item">Blogs</span>></a>
<a href="/events"><span class="menu-item">Events</span>></a>
<a href="{% url 'groups' %}"><span class="menu-item">Groups</span>></a>
<a href="{% url 'newsletters' %}"><span class="menu-item">Newsletters</span>></a>
</div>
<div class="core-menu">
<div class="container">
<a href="{% url 'contribute' %}"><span class="menu-item">Contribute</span></a>
<a href="{% url 'subscribe' %}"><span class="menu-item">Subscribe</span>></a>
<a href="{% url 'search' %}"><span class="menu-item">Search</span></a>
<a href="{% url 'help' %}"><span class="menu-item">Help</span>></a>
<a href="{% url 'contact' %}"><span class="menu-item">Contact</span>></a>
</div>
</div>
</header>
<section class="container">
{% site_messages %}
</section>
{% block container %}
<main class="container">
{% block intro %}{% endblock %}
{% block title %}
<h2 class="page-title">{{ title }}</h2>
{% endblock %}
<section>
{% block content %}{% endblock %}
</section>
{% endblock %}
</main>
<footer>
<section class="row">
<section class="one-half column left">
<div class="container">
<p>Love GLAMR data? You might also like the <a href="https://librarymap.hugh.run">Australian Library Map</a></p>
</div>
</section>
<section class="one-half column">
<div class="container">
<p>Made & maintained by <a href="https://www.hughrundle.net">Hugh Rundle</a> and hosted by <a href="https://newcardigan.org">newCardigan</a></p>
<p>Built with <a href="https://djangoproject.com">Django</a> and 💖</p>
</div>
</section>
</section>
</footer>
<script src="/static/js/glamr.js"></script>
</body>
</html>

View file

@ -0,0 +1,7 @@
<section class="message-box">
{% for msg in messages %}
<div class="alert info">
{{ msg.message }}
</div>
{% endfor %}
</section>

View file

@ -0,0 +1,20 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Processing...</div>
</div>
<form action="{% url 'register-group' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{{ form }}
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Processing...</div>
</div>
<form action="{% url 'register-newsletter' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{{ form }}
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,63 @@
{% extends "layout.html" %}
{% block content %}
<form action="{% url 'search' %}" method="get">
<label for="keywords">Search for keywords across blog posts, groups, newsletters, and conferences</label>
<input type="text" name="q" value="{% if query %}{{ query }}{% endif %}">
<button class="button" type="submit">Search!</button>
</form>
{% if items %}
{% for item in items %}
<div class="card">
<div class="card-title">
<a href="{{ item.url }}"><h3>{% if item.title %} {{ item.title }} {% else %} {{item.name}} {% endif %}</h3></a>
<p class="meta">
{% if item.author_name or item.author %}
<span class="author_name">{% if item.author_name%} {{ item.author_name }} {% else %} {{ item.author_name }} {% endif %}</span> |
{% endif %}
{% if item.blog %}
<span class="blog_title">{{ item.blog.title }}</span>
{% elif item.description %}
<p class="">{{ item.description }}</p>
{% elif item.details %}
<p class="">{{ item.details }}</p>
{% endif %}
</p>
{% if item.tags %}
{% for tag in item.tags.all %}
<a href="{% url 'browse' %}?q={{ tag.name|urlencode }}"><span class="badge">{{ tag.name }}</span></a>
{% endfor %}
{% elif item.category_name %}
<span class="badge badge_{{item.category}}">{{item.category_name}}</span>
{% endif %}
</div>
</div>
{% endfor %}
<section class=" pagination row">
<span class="four columns">
{% if items.has_previous %}
<a href="?q={{ query }}&page=1">&laquo; first</a> |
<a href="?q={{ query }}&page={{ items.previous_page_number }}">previous</a>
{% else %}
<span class="inactive">&laquo; first | previous</span>
{% endif %}
</span>
<span class="current four columns">
Page {{ items.number }} of {{ items.paginator.num_pages }}
</span>
<span class="current four columns">
{% if items.has_next %}
<a href="?q={{ query }}&page={{ items.next_page_number }}">next</a> |
<a href="?q={{ query }}&page={{ items.paginator.num_pages }}">last &raquo;</a>
{% else %}
<span class="inactive">next | last &raquo;</span>
{% endif %}
</span>
</section>
{% elif query %}
<p><em>No items found for "{{ query }}"</em></p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "layout.html" %}
{% block content %}
<div class="loader" hidden>
<div class="lds-heart"><div></div></div>
<div class="lds-text">Processing...</div>
</div>
<form action="{% url 'subscribe-email' %}" method="post">
{% csrf_token %}
<div class="row">
<div class="columns eight">
{{ form }}
<input class="button-primary u-pull-right" type="submit" value="Register!">
</div>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "layout.html" %}
{% block container %}
<main class="container pop-boxes">
{% block intro %}{% endblock %}
{% block title %}
<h2 class="page-title">{{ title }}</h2>
{% endblock %}
<section>
{% block content %}
<section class="subscribe">
<section class="pop">
<hgroup>
<h3>Subscribe via email</h3>
</hgroup>
<p>Get a weekly update listing the latest blog posts, open calls for papers, and upcoming conferences.</p>
<div>
<a class="button" href="{% url 'subscribe-email' %}">Subscribe via email</a>
</div>
</section>
<section class="pop">
<hgroup>
<h3>Subscribe via ActivityPub</h3>
</hgroup>
<p>Follow the <code class="code">@blogs@ausglam.space</code> bot to see an announcement every time a new blog post , event, or call for papers is published.</p>
<form method="post" action="{% url 'subscribe' %}">
{% csrf_token %}
<label for="username">Enter address you want to follow from</label>
{{ form.errors.username }}
<input type="text" name="username" placeholder="@user@example.social">
<button class="button u-pull-right" type="submit">Follow</button>
</form>
</section>
<section class="pop">
<hgroup>
<h3>Subscribe via RSS</h3>
</hgroup>
<p>Subscribe to the blogs feed in your favourite RSS reader to get a combined feed with every new article.</p>
<p>Or subscribe to the events feed to be the first to know about upcoming events.</p>
<div>
<a class="button" href="{% url 'article-feed' %}">blog posts</a>
<a class="button" href="{% url 'event-feed' %}">events</a>
</div>
</section>
</section>
{% endblock %}
</section>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "layout.html" %}
{% block content %}
{% if register_type == "message" %}
<p>Thanks for getting in touch!</p>
{% else %}
<p>Thankyou for registering your {{ register_type }}.</p>
{% if register_type == "email address" %}
<p>Check your inbox to confirm your subscription. You can opt-out at any time by clicking the link at the bottom of the weekly emails.</p>
{% else %}
<p>Your {{ register_type }} registration will be reviewed before being added to <em>Aus GLAMR</em>.</p>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,7 @@
{% extends "layout.html" %}
{% block content %}
<p>You have been unsubscribed from weekly emails.</p>
{% endblock %}

View file

View file

@ -0,0 +1,12 @@
"""custom tags"""
from django import template
from blogs import models
register = template.Library()
@register.inclusion_tag("messages.html")
def site_messages():
"""display site messages"""
return {"messages": models.SiteMessage.objects.all()}

View file

@ -0,0 +1,66 @@
"""test management commands"""
from datetime import datetime, timedelta
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from blogs import models, management
class ManagementTestCase(TestCase):
"""test management commands"""
def setUp(self):
"""set up some things to announce"""
self.conf = models.Event.objects.create(
name="Amazing Conf",
url="https://test.com",
category="LIB",
start_date=timezone.now() + timedelta(days=3),
activitypub_account_name="@conf@conf.conf",
approved=True,
announcements=1,
)
self.cfp = models.CallForPapers.objects.create(
event=self.conf,
name="Call for Papers for Amazing Conf",
opening_date=timezone.now() + timedelta(days=30),
closing_date=timezone.now() + timedelta(days=1),
)
self.cfp = models.CallForPapers.objects.create(
event=self.conf,
name="Call for posters",
opening_date=timezone.now() - timedelta(days=30),
closing_date=timezone.now() - timedelta(days=1),
)
def test_queue_announcements(self):
"""both in one"""
call_command("queue_announcements")
# call for posters is in the past, so should not be announced
self.assertEqual(models.Announcement.objects.count(), 2)
# event
announcement = models.Announcement.objects.first()
start_date = timezone.now() + timedelta(days=3)
date = start_date.strftime("%a %d %b %Y")
status = f"Amazing Conf (@conf@conf.conf) is a event about Libraries, starting on {date}!\n\nhttps://test.com"
self.assertEqual(announcement.status, status)
# cfp
announcement = models.Announcement.objects.last()
opening_date = timezone.now() + timedelta(days=30)
closing_date = timezone.now() + timedelta(days=1)
opening_date_str = opening_date.strftime("%a %d %b %Y")
closing_date_str = closing_date.strftime("%a %d %b %Y")
status = f"Amazing Conf Call for Papers for Amazing Conf is open from {opening_date_str}, closing on {closing_date_str}!\n\nMore info at https://test.com"
self.assertEqual(announcement.status, status)

179
blogs/tests/test_models.py Normal file
View file

@ -0,0 +1,179 @@
"""model tests"""
from datetime import date
from django.test import TestCase
from django.utils import timezone
from blogs import models
class BlogTestCase(TestCase):
"""test cases for Blog model"""
def setUp(self):
"""set up test blog"""
self.blog = models.Blog.objects.create(
title="mya awesome blog",
url="https://test.com",
feed="https://test.com/feed.xml",
category="LIB",
)
def test_get_absolute_url(self):
"""get_absolute_url class function"""
self.assertEqual(self.blog.get_absolute_url(), "https://test.com")
def test_set_success(self):
"""set_success class function"""
self.blog.failing = True
self.blog.save()
self.blog.set_success()
self.assertEqual(self.blog.failing, False)
def test_set_failing(self):
"""set_failing class function"""
self.blog.failing = False
self.blog.save()
self.blog.set_failing()
self.assertEqual(self.blog.failing, True)
def test_announce_article(self):
"""announcing a blog article"""
article = models.Article.objects.create(
title="My article",
author_name="Hugh",
url="https://example.blog/1",
blog=self.blog,
pubdate=timezone.now(),
guid="123-123-123",
)
article.announce()
status = f"My article (Hugh on mya awesome blog)\n\nhttps://example.blog/1"
self.assertTrue(models.Announcement.objects.filter(status=status).exists())
class ConferenceTestCase(TestCase):
"""test event functions"""
def setUp(self):
"""set up test conf"""
self.conf = models.Event.objects.create(
name="Awesome Conf",
url="https://test.com",
category="LIB",
start_date=date.fromisoformat("2030-12-01"),
activitypub_account_name="@conf@conf.conf",
approved=True,
)
self.cfp = models.CallForPapers.objects.create(
event=self.conf,
name="Call for Tests",
opening_date=date.fromisoformat("2030-11-01"),
closing_date=date.fromisoformat("2030-11-30"),
)
def test_announce(self):
"""test announcing a conf"""
self.conf.announce()
announcement = models.Announcement.objects.first()
self.assertEqual(
announcement.status,
f"Awesome Conf (@conf@conf.conf) is a event about Libraries, starting on Sun 01 Dec 2030!\n\nhttps://test.com",
)
def test_announce_cfp(self):
"""test announcing a conf CFP"""
self.cfp.announce()
announcement = models.Announcement.objects.first()
self.assertEqual(
announcement.status,
f"Awesome Conf Call for Tests is open from Fri 01 Nov 2030, closing on Sat 30 Nov 2030!\n\nMore info at https://test.com",
)
class GroupTestCase(TestCase):
"""test group functions"""
def setUp(self):
"""set up test gropu"""
self.group = models.Group.objects.create(
name="Awesome group",
url="https://test.com",
category="LIB",
type="KBIN",
registration_url="https://test.com/reg",
)
def test_announce(self):
"""test announcing a group"""
self.group.announce()
announcement = models.Announcement.objects.first()
self.assertEqual(
announcement.status,
f"Awesome group is a KBin server about Libraries!\n\nJoin them: https://test.com/reg",
)
class NewsletterTestCase(TestCase):
"""test newsletter functions"""
def setUp(self):
"""set up test newsletter"""
self.news = models.Newsletter.objects.create(
name="Awesome news",
author="Hugh",
url="https://test.com",
category="ARC",
)
def test_announce(self):
"""test announcing a group"""
self.news.announce()
announcement = models.Announcement.objects.first()
self.assertEqual(
announcement.status,
f"Awesome news is a newsletter about Archives from Hugh. Check it out:\n\nhttps://test.com",
)
class UtilsTestCase(TestCase):
"""test utility functions"""
def test_announcement(self):
"""test announcing"""
# TODO: mock mastodon somehow
# announcement = models.Announcement.objects.create(
# status="I have something to say!",
# summary="boasting",
# )
# announcement.announce()
# self.assertEqual(models.Announcement.objects.count(), 0)
def test_content_warning(self):
"""test CWs"""
warning = models.ContentWarning.objects.create(
match_text="horrible thing", display="bad shit"
)
self.assertTrue(warning.is_in("I saw a horrible thingy"))

View file

@ -0,0 +1,8 @@
"""test utility functions"""
# TODO:
"""
get_feed_info
get_blog_info
get_webfinger_subscribe_uri
"""

224
blogs/tests/test_views.py Normal file
View file

@ -0,0 +1,224 @@
"""test views"""
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.core import mail
from django.test import Client, TestCase
from django.test.client import RequestFactory
from django.urls import reverse
from django.utils import timezone
from blogs import forms, models, views
class PublicTests(TestCase):
"""Public views test cases"""
def setUp(self):
start_date = timezone.now()
self.factory = RequestFactory()
self.glam_conf = models.Event.objects.create(
name="Awesome conf",
url="https://awesome.conf",
category="GLAM",
description="An awesome conf",
start_date=start_date,
)
def test_public_views_load(self):
"""
Do public views load correctly?
"""
home = self.client.get(reverse("home"))
self.assertEqual(home.status_code, 200)
browse = self.client.get(reverse("browse"))
self.assertEqual(browse.status_code, 200)
search = self.client.get(reverse("search"))
self.assertEqual(search.status_code, 200)
help = self.client.get(reverse("help"))
self.assertEqual(help.status_code, 200)
contribute = self.client.get(reverse("contribute"))
self.assertEqual(contribute.status_code, 200)
contact = self.client.get(reverse("contact"))
self.assertEqual(contact.status_code, 200)
blogs_response = self.client.get(reverse("blogs"))
self.assertEqual(blogs_response.status_code, 200)
confs_response = self.client.get(reverse("conferences"))
self.assertEqual(confs_response.status_code, 200)
groups_response = self.client.get(reverse("groups"))
self.assertEqual(groups_response.status_code, 200)
news_response = self.client.get(reverse("newsletters"))
self.assertEqual(news_response.status_code, 200)
rblog_response = self.client.get(reverse("register-blog"))
self.assertEqual(rblog_response.status_code, 200)
submit = self.client.get(reverse("submit-blog-registration"))
self.assertEqual(submit.status_code, 200) # 301?
rconf_response = self.client.get(reverse("register-event"))
self.assertEqual(rconf_response.status_code, 200)
rcfp_response = self.client.get(reverse("register-cfp"))
self.assertEqual(rcfp_response.status_code, 200)
rgroup_response = self.client.get(reverse("register-group"))
self.assertEqual(rgroup_response.status_code, 200)
rnews_response = self.client.get(reverse("register-newsletter"))
self.assertEqual(rnews_response.status_code, 200)
rnews_response = self.client.get(
reverse("thankyou", args=({"register_type": "blog"}))
)
self.assertEqual(rnews_response.status_code, 200)
subscribe = self.client.get(reverse("subscribe"))
self.assertEqual(subscribe.status_code, 200)
af = self.client.get(reverse("article-feed"))
self.assertEqual(af.status_code, 200)
cf = self.client.get(reverse("event-feed"))
self.assertEqual(cf.status_code, 200)
def test_confirm_register_blog(self):
"""post final event registration form"""
view = views.ConfirmBlogRegistration.as_view()
form = forms.ConfirmBlogForm()
form.data["title"] = "My blog"
form.data["author_name"] = "Bob Bobson"
form.data["url"] = "https://www.example.com"
form.data["feed"] = "https://www.example.com/feed"
form.data["category"] = "LIB"
request = self.factory.post("/submit-blog-registration", form.data)
request.user = AnonymousUser()
view(request)
exists = models.Blog.objects.filter(title="My blog").exists()
self.assertTrue(exists)
def test_register_conference(self):
"""post event registration form"""
view = views.RegisterConference.as_view()
form = forms.RegisterConferenceForm()
form.data["name"] = "My event"
form.data["description"] = "A conf for gallerists"
form.data["url"] = "https://awesome.conf/cfp"
form.data["category"] = "GAL"
form.data["start_date"] = "30/01/2024"
request = self.factory.post("register-event/", form.data)
request.user = AnonymousUser()
view(request)
exists = models.Event.objects.filter(name="My event").exists()
self.assertTrue(exists)
def test_register_cfp(self):
"""post CFP registration form"""
view = views.RegisterCallForPapers.as_view()
form = forms.RegisterCallForPapersForm()
form.data["event"] = self.glam_conf.id
form.data["name"] = "Call for Papers"
form.data["url"] = "https://www.example.com"
form.data["category"] = "GLAM"
form.data["opening_date"] = "01/01/2024"
form.data["closing_date"] = "28/01/2024"
request = self.factory.post("register-cfp/", form.data)
request.user = AnonymousUser()
view(request)
exists = models.CallForPapers.objects.filter(name="Call for Papers").exists()
self.assertTrue(exists)
def test_register_group(self):
"""post group registration form"""
view = views.RegisterGroup.as_view()
form = forms.RegisterGroupForm()
form.data["name"] = "GLAMR testers"
form.data["category"] = "GLAM"
form.data["type"] = "KBIN"
form.data["url"] = "https://kibin.test"
form.data["registration_url"] = "https://kbin.test/glamr"
form.data["description"] = "GLAMR testers"
request = self.factory.post("register-group/", form.data)
request.user = AnonymousUser()
view(request)
exists = models.Group.objects.filter(name="GLAMR testers").exists()
self.assertTrue(exists)
def test_register_newsletter(self):
"""post newsletter registration form"""
view = views.RegisterNewsletter.as_view()
form = forms.RegisterNewsletterForm()
form.data["name"] = "My newsletter"
form.data["author"] = "Bob Bobson"
form.data["url"] = "https://www.example.com"
form.data["category"] = "LIB"
request = self.factory.post("register-newsletter/", form.data)
request.user = AnonymousUser()
view(request)
exists = models.Newsletter.objects.filter(name="My newsletter").exists()
self.assertTrue(exists)
def test_contact(self):
"""post message"""
view = views.Contact.as_view()
form = forms.ContactForm()
form.data["from_email"] = "example@example.mail"
form.data["subject"] = "Hello"
form.data["message"] = "Hi there"
request = self.factory.post("contact/", form.data)
request.user = AnonymousUser()
view(request)
# Test that one message has been sent.
self.assertEqual(len(mail.outbox), 1)
# Verify that the subject of the first message is correct.
self.assertEqual(mail.outbox[0].subject, "Message via Aus GLAMR: Hello")
def test_search(self):
"""post search query"""
# TODO
pass
def test_browse(self):
"""post browse tags query"""
# TODO
pass

124
blogs/utilities.py Normal file
View file

@ -0,0 +1,124 @@
"""useful functions that are not associated with models or views"""
import re
from bs4 import BeautifulSoup
import feedparser
import requests
from django.conf import settings
from django.core.mail import EmailMessage
from django.utils.encoding import iri_to_uri
headers = {"user-agent": "Aus-GLAM-Blogs/0.0.1"}
timeout = (4, 13)
def get_feed_info(feed):
"""parse a feed for basic blog info"""
b = feedparser.parse(feed)
blog = {}
blog["feed"] = feed
blog["title"] = getattr(b.feed, "title", "")
blog["author_name"] = getattr(b.feed, "author", None)
blog["description"] = getattr(b.feed, "subtitle", "")
blog["last_updated"] = getattr(b.feed, "updated_parsed", "")
return blog
def get_blog_info(url):
"""given a url, return info from the site
including the feed URL"""
try:
r = requests.get(url, headers=headers, timeout=timeout)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
links = soup.find_all(type=["application/rss+xml", "application/atom+xml"])
blog_info = {}
try:
author = soup.select_one('meta[name="author"]').get("content")
except AttributeError:
try:
author = soup.select_one('meta[name="creator"]').get("content")
except AttributeError:
author = ""
if len(links) > 0:
blog_info = get_feed_info(links[0].get("href"))
blog_info["title"] = blog_info["title"] or soup.title.string
blog_info["author_name"] = blog_info["author_name"] or author
return blog_info
except requests.Timeout:
print(f"TIMEOUT error registering {url}, trying longer timeout")
r = requests.get(url, headers=headers, timeout=(31, 31))
r.raise_for_status() # let it flow through, a timeout here means the site is unreasonably slow
except Exception as e:
print(f"CONNECTION ERROR when registering {url}")
print(e)
return False
def get_webfinger_subscribe_uri(username):
"""given a username, return the url needed to follow the user"""
try:
regex = re.match(r"(?:.*@)(.*)", username)
domain = regex.group(1)
if username[0] == "@":
username = username[1:]
webfinger_url = (
f"https://{domain}/.well-known/webfinger/?resource=acct:{username}"
)
r = requests.get(webfinger_url, headers=headers, timeout=timeout)
r.raise_for_status()
except requests.Timeout:
print(f"TIMEOUT error finding {username}, trying longer timeout")
r = requests.get(webfinger_url, headers=headers, timeout=(31, 31))
r.raise_for_status() # let it flow through, a timeout here means the site is unreasonably slow
except Exception as e:
print(f"CONNECTION ERROR when subscribing via {username}")
print(e)
return None
data = r.json()
user_id = False
template = False
for link in data["links"]:
if link["rel"] == "self":
user_id = link["href"]
if link["rel"] == "http://ostatus.org/schema/1.0/subscribe":
template = link["template"]
if user_id and template:
encoded_user = iri_to_uri("blogs@ausglam.space")
uri = template.replace("{uri}", encoded_user)
return uri
return None
def send_email(subject, message, recipient):
"""send an email"""
msg = EmailMessage(
subject,
message,
settings.DEFAULT_FROM_EMAIL,
[recipient],
)
msg.content_subtype = "html"
msg.send()

28
blogs/views/__init__.py Normal file
View file

@ -0,0 +1,28 @@
"""available views"""
from .public import (
ConfirmBlogRegistration,
HomeFeed,
Blogs,
Browse,
Contribute,
Contact,
Conferences,
ConfirmEmail,
CallsForPapers,
Groups,
Help,
Newsletters,
RegisterBlog,
RegisterCallForPapers,
RegisterConference,
RegisterGroup,
RegisterNewsletter,
Search,
Subscribe,
SubscribeEmail,
Thankyou,
UnsubscribeEmail,
)
from .feeds import ArticleFeed, EventFeed

73
blogs/views/feeds.py Normal file
View file

@ -0,0 +1,73 @@
"""rss feeds"""
from django.contrib.syndication.views import Feed
from django.utils.translation import gettext_lazy as _
from blogs.models.blog import Article
from blogs.models.event import Event
# pylint: disable=R6301
class ArticleFeed(Feed):
"""Combined RSS feed for all the articles"""
title = "Aus GLAMR Blogs"
link = "/feeds/blogs"
description = "Posts from Australasian Galleries, Libraries, Archives, Museums, Records and associated blogs"
def items(self):
"""each article in the feed"""
return Article.objects.order_by("-pubdate")[:20]
def item_title(self, item):
"""article title"""
return item.title
def item_description(self, item):
"""article description"""
return getattr(item, "description", None) or getattr(item, "summary", None)
def item_author_name(self, item):
"""article author"""
return item.author_name
def item_pubdate(self, item):
"""article publication date"""
return item.pubdate
def item_categories(self, item):
"""article tags"""
categories = []
for tag in item.tags.all():
categories.append(tag.name)
return categories
class EventFeed(Feed):
"""Combined RSS feed for all the articles"""
title = "Aus GLAMR events"
link = "/feeds/events"
description = "Australasian events for Galleries, Libraries, Archives, Museums, Records workers"
def items(self):
"""event items for the feed"""
return Event.objects.order_by("-start_date")[:20]
def item_title(self, item):
"""event name"""
date = item.start_date.strftime("%d %b %Y")
return f"{item.name} ({date})"
def item_description(self, item):
"""event description"""
return item.description
def item_pubdate(self, item):
"""date event was registered"""
return item.pub_date
def item_categories(self, item):
"""event GLAMR category"""
return [_(item.category)]

570
blogs/views/public.py Normal file
View file

@ -0,0 +1,570 @@
"""public views (no need to log in)"""
# pylint: disable=R6301
import os
from itertools import chain
from operator import attrgetter
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.contrib.postgres.search import SearchRank, SearchVector
from django.utils.translation import gettext_lazy as _
from django.core.mail import EmailMessage
from django.core.paginator import Paginator
from django.db.models import Count
from django.shortcuts import render, redirect
from django.utils import timezone
from django.views import View
from blogs import forms, models
from blogs.utilities import get_blog_info, get_webfinger_subscribe_uri
class HomeFeed(View):
"""the home feed when someone visits the site"""
def get(self, request):
"""display home page"""
path = os.path.join(settings.BASE_DIR, "static/")
print(path)
latest = models.Article.objects.order_by("-pubdate")[:10]
data = {"title": "Latest blog posts", "latest": latest}
return render(request, "index.html", data)
class Blogs(View):
"""browse the list of blogs"""
def get(self, request):
"""here they are"""
blogs = models.Blog.objects.filter(approved=True)
for blog in blogs:
blog.category_name = models.Category(blog.category).label
data = {"title": "Blogs and websites", "blogs": blogs}
return render(request, "browse/blogs.html", data)
class Conferences(View):
"""browse the list of conferences"""
def get(self, request):
"""here they are"""
now = timezone.now()
cons = models.Event.objects.filter(approved=True, start_date__gte=now).order_by(
"start_date"
)
for con in cons:
con.category_name = models.Category(con.category).label
con.call_for_papers = con.cfp.all().last()
if con.call_for_papers and (con.call_for_papers.closing_date > now.date()):
date = con.call_for_papers.closing_date.strftime("%a %d %b %Y")
con.call_for_papers = f"{con.call_for_papers.name} closes {date}"
data = {"title": "Upcoming events", "cons": cons}
return render(request, "browse/events.html", data)
class CallsForPapers(View):
"""browse the list of CFPs"""
def get(self, request):
"""here they are"""
now = timezone.now()
cfps = models.CallForPapers.objects.filter(
approved=True, closing_date__gte=now
).order_by("closing_date")
data = {"title": "Calls for Papers open now", "cfps": cfps}
return render(request, "browse/cfp.html", data)
class Groups(View):
"""browse the list of groups"""
def get(self, request):
"""here they are"""
groups = models.Group.objects.filter(approved=True).order_by("name")
for group in groups:
group.category_name = models.Category(group.category).label
group.reg_type = models.utils.GroupType(group.type).label
data = {"title": "Groups and discussion lists", "groups": groups}
return render(request, "browse/groups.html", data)
class Newsletters(View):
"""browse the list of groups"""
def get(self, request):
"""here they are"""
news = models.Newsletter.objects.filter(approved=True).order_by("name")
for letter in news:
letter.category_name = models.Category(letter.category).label
data = {"title": "Newsletters", "news": news}
return render(request, "browse/newsletters.html", data)
class RegisterBlog(View):
"""register a blog"""
def get(self, request):
"""the registration page with a form"""
form = forms.RegisterBlogForm()
data = {"title": "Register your blog", "form": form}
return render(request, "blogs/register.html", data)
def post(self, request):
"""receive POSTED RegisterBlogForm"""
form = forms.RegisterBlogForm(request.POST)
if form.is_valid():
try:
blog_info = get_blog_info(form.cleaned_data["url"])
except KeyError:
return render(
request,
"blogs/register.html",
{"title": "Complete blog registration", "form": form},
)
data = {
"title": "Complete blog registration",
"form": form,
}
if blog_info:
data["blog_info"] = blog_info
else:
data["error"] = "Could not auto-discover your feed info, please enter manually"
return render(request, "blogs/confirm-register.html", data)
data = {
"title": "Register your blog",
"form": form
}
return render(request, "blogs/register.html", data)
class ConfirmBlogRegistration(View):
"""submit validated and pre-filled registration form"""
def get(self, request):
"""the confirm registration page"""
form = forms.ConfirmBlogForm(request.POST)
data = {"title": "Complete blog registration", "form": form}
return render(request, "blogs/confirm-register.html", data)
def post(self, request):
"""the final form has been submitted!"""
form = forms.ConfirmBlogForm(request.POST)
if form.is_valid():
blog = form.save()
send_email("blog", blog)
return redirect("/thankyou/blog")
data = {"title": "Oops!", "form": form}
return render(request, "blogs/confirm-register.html", data)
class RegisterConference(View):
"""register a event"""
def get(self, request):
"""the registration page with a form"""
form = forms.RegisterConferenceForm()
data = {"title": "Register your event", "form": form}
return render(request, "events/register.html", data)
def post(self, request):
"""receive form"""
form = forms.RegisterConferenceForm(request.POST)
if form.is_valid():
conf = form.save()
send_email("event", conf)
cfp_form = forms.RegisterCallForPapersForm({"event": conf.id})
data = {
"title": "Register your Call for Papers",
"form": cfp_form,
"conf_name": conf.name,
}
return render(request, "events/cfp.html", data)
data = {"title": "Complete blog registration", "form": form, "errors": True}
return render(request, "events/register.html", data)
class RegisterCallForPapers(View):
"""register a RegisterCallForPapers"""
def get(self, request):
"""the registration page with a form"""
form = forms.RegisterCallForPapersForm()
data = {"title": "Register your Call For Papers", "form": form}
return render(request, "events/cfp.html", data)
def post(self, request):
"""receive POSTED RegisterCallForPapersForm"""
form = forms.RegisterCallForPapersForm(request.POST)
if form.is_valid():
cfp = form.save()
send_email("Call for Papers", cfp)
return redirect("/thankyou/Call For Papers")
data = {
"title": "Register your Call For Papers",
"form": form,
"errors": True,
}
return render(request, "events/cfp.html", data)
class RegisterGroup(View):
"""register a group"""
def get(self, request):
"""the registration page with a form"""
form = forms.RegisterGroupForm()
data = {"title": "Register your group", "form": form}
return render(request, "register-group.html", data)
def post(self, request):
"""receive POSTED form"""
form = forms.RegisterGroupForm(request.POST)
if form.is_valid():
group = form.save()
send_email("group", group)
return redirect("/thankyou/group")
data = {"title": "Oops!", "form": form, "errors": True}
return render(request, "register-group.html", data)
class RegisterNewsletter(View):
"""register a newsletter"""
def get(self, request):
"""the registration page with a form"""
form = forms.RegisterNewsletterForm()
data = {"title": "Register your newsletter", "form": form}
return render(request, "register-newsletter.html", data)
def post(self, request):
"""receive POSTED form"""
form = forms.RegisterNewsletterForm(request.POST)
if form.is_valid():
newsletter = form.save()
send_email("newsletter", newsletter)
return redirect("/thankyou/newsletter")
data = {"title": "Oops!", "form": form, "errors": True}
return render(request, "register-newsletter.html", data)
class Search(View):
"""search functions"""
def get(self, request, articles=None):
"""display search page"""
query = request.GET.get("q")
article_vector = (
SearchVector("tags__name", weight="A")
+ SearchVector("title", weight="B")
+ SearchVector("description", weight="C")
)
articles = (
models.Article.objects.annotate(rank=SearchRank(article_vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)
conference_vector = SearchVector("name", weight="A") + SearchVector(
"description", weight="C"
)
conferences = (
models.Event.objects.annotate(rank=SearchRank(conference_vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)
cfp_vector = SearchVector("name", weight="B") + SearchVector(
"details", weight="C"
)
cfps = (
models.CallForPapers.objects.annotate(rank=SearchRank(cfp_vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)
news_vector = SearchVector("name", weight="A") + SearchVector(
"description", weight="C"
)
newsletters = (
models.Newsletter.objects.annotate(rank=SearchRank(news_vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)
group_vector = SearchVector("name", weight="A") + SearchVector(
"description", weight="C"
)
groups = (
models.Event.objects.annotate(rank=SearchRank(group_vector, query))
.filter(rank__gte=0.1)
.order_by("-rank")
)
combined = sorted(
chain(articles, conferences, cfps, newsletters, groups),
key=attrgetter("rank"),
reverse=True,
)
for item in combined:
if hasattr(item, "category"):
item.category_name = models.Category(item.category).label
if hasattr(item, "event"):
item.category = models.Category(item.event.category)
item.category_name = models.Category(item.event.category).label
paginator = Paginator(combined, 10)
page_number = request.GET.get("page")
paged = paginator.get_page(page_number)
data = {"title": "Search Aus GLAMR", "items": paged, "query": query}
return render(request, "search.html", data)
class Browse(View):
"""browse by clicking on a tag"""
def get(self, request):
"""display browse results"""
query = request.GET.get("q")
results = models.Article.objects.filter(tags__name=query).order_by("-pubdate")
trending = models.Tag.objects.annotate(count=Count("articles")).order_by(
"-count"
)[:10]
paginator = Paginator(results, 10)
page_number = request.GET.get("page")
paged = paginator.get_page(page_number)
data = {
"title": f"Articles tagged '{query}'",
"trending": trending,
"items": paged,
"query": query,
}
return render(request, "browse/tags.html", data)
class Subscribe(View):
"""Subscribe page showing RSS feed link and mastodon follow form"""
def get(self, request):
"""display subscribe page"""
return render(request, "subscribe.html", {})
def post(self, request):
"""subscribe to Mastodon account"""
form = forms.SubscribeViaMastodon(request.POST)
if form.is_valid():
try:
username = form.cleaned_data["username"]
uri = get_webfinger_subscribe_uri(username)
if uri:
return redirect(uri)
form.add_error(
"username", "Enter a valid username e.g. @example@ausglam.space"
)
except KeyError:
pass
return render(request, "subscribe.html", {"form": form})
class SubscribeEmail(View):
"""Subscribe to weekly emails"""
def get(self, request):
"""display subscribe page"""
form = forms.SubscribeEmailForm()
data = {"title": "Get weekly email updates", "form": form}
return render(request, "subscribe-email.html", data)
def post(self, request):
"""subscribe to Mastodon account"""
form = forms.SubscribeEmailForm(request.POST)
if form.is_valid():
user = form.save()
user.send_confirmation_email()
return redirect("/thankyou/email%20address")
return render(request, "subscribe.html", {"form": form})
class ConfirmEmail(View):
"""Hit this when click to confirm email subscription"""
def get(self, request, token, user_id):
"""display confirmation page"""
user = get_object_or_404(models.Subscriber, id=user_id, token=token)
user.confirmed = True
user.save()
return render(request, "confirm-email.html", {"title": "Confirmed!"})
class UnsubscribeEmail(View):
"""Hit this when click to confirm email subscription"""
def get(self, request, token, user_id):
"""unsubscribe conf page"""
user = get_object_or_404(models.Subscriber, id=user_id, token=token)
user.delete()
return render(request, "unsubscribe.html", {"title": "Sorry to see you go"})
class Contribute(View):
"""help page"""
def get(self, request):
"""display contribute page"""
data = {
"title": "Contribute",
}
return render(request, "contribute.html", data)
class Help(View):
"""help page"""
def get(self, request):
"""display help page"""
data = {
"title": "Help",
}
return render(request, "help.html", data)
class Contact(View):
"""contact Hugh"""
def get(self, request):
"""the contact page with a form"""
form = forms.ContactForm()
data = {"title": "Get in touch", "form": form}
return render(request, "contact.html", data)
def post(self, request):
"""receive POSTED form"""
form = forms.ContactForm(request.POST)
if form.is_valid():
from_email = form.cleaned_data["from_email"]
subject = form.cleaned_data["subject"]
message = form.cleaned_data["message"]
send_contact_email(from_email, subject, message)
return redirect("/thankyou/message")
data = {"title": "Oops!", "form": form, "errors": True}
return render(request, "contact.html", data)
class Thankyou(View):
"""thankyou for registering page"""
def get(self, request, register_type):
"""display thankyou page"""
data = {"title": "Thanks!", "register_type": register_type}
return render(request, "thanks.html", data)
def send_email(instance, obj):
"""send an email alert to admin"""
html_message = f"<html><body>\
<p>A new {instance} has been registered:</p>\
<strong>{obj.title if hasattr(obj, 'title') else obj.name}</strong></p>\
<p>To approve or reject visit <a href='https://{settings.DOMAIN}/admin'>{settings.DOMAIN}/admin</a></p>\
</body></html>"
msg = EmailMessage(
f"📥 Someone has registered a new {instance}!",
html_message,
settings.DEFAULT_FROM_EMAIL,
[settings.ADMIN_EMAIL],
)
msg.content_subtype = "html"
msg.send()
def send_contact_email(from_email, subject, message):
"""email the message"""
html_message = f"<html><body><p>{message}</p></body></html>"
msg = EmailMessage(
f"Message via Aus GLAMR: {subject}",
html_message,
f"{from_email}",
[settings.ADMIN_EMAIL],
)
msg.content_subtype = "html"
msg.send()

26
docker-compose.yml Normal file
View file

@ -0,0 +1,26 @@
version: '3'
services:
db:
image: postgres:13
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- main
web:
build: .
env_file: .env
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
depends_on:
- db
networks:
- main
ports:
- "8000:8000"
volumes:
pgdata:
networks:
main:

78
glamr-dev Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Thanks Mouse Reeve for letting me steal your idea.
# exit on errors
set -e
function runweb {
docker compose run --rm web "$@"
}
function execdb {
docker compose exec db $@
}
function migrate {
runweb python manage.py migrate "$@"
}
CMD=$1
if [ -n "$CMD" ]; then
shift
fi
# show commands as they're executed
set -x
case "$CMD" in
announce)
runweb python manage.py announce
;;
black)
docker compose run --rm web black ausglamr blogs
;;
check_feeds)
runweb python manage.py check_feeds
;;
collectstatic)
runweb python manage.py collectstatic
;;
createsuperuser)
runweb python manage.py createsuperuser --no-input
;;
dbshell)
execdb psql -U ${POSTGRES_USER} ${POSTGRES_DB}
;;
manage)
runweb python manage.py "$@"
;;
makemigrations)
runweb python manage.py makemigrations "$@"
;;
migrate)
migrate "$@"
;;
pylint)
docker compose run --rm web pylint ausglamr blogs
;;
queue_announcements)
runweb python manage.py queue_announcements
;;
resetdb)
docker compose rm -svf
docker volume rm -f ausglamr_pgdata
migrate
;;
send_weekly_email)
runweb python manage.py send_weekly_email
;;
test)
runweb python manage.py test "$@"
;;
*)
set +x
echo "That is not a command"
;;
esac

22
manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ausglamr.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

11
requirements.txt Normal file
View file

@ -0,0 +1,11 @@
beautifulsoup4==4.12.2
Django==4.2.7
environs==9.5.0
feedparser==6.0.10
psycopg2==2.9.5
requests==2.31.0
# dev
black==23.12.0
pylint==3.0.3