From 53df5c5035597d1c9e0fc533df913f107e7672ea Mon Sep 17 00:00:00 2001 From: Hugh Rundle Date: Thu, 4 Jan 2024 11:54:56 +1100 Subject: [PATCH] initial commit --- .env.example | 31 + .gitignore | 2 + .pylintrc | 9 + Dockerfile | 6 + README.md | 70 +++ admin.py | 2 + ausglamr/__init__.py | 0 ausglamr/asgi.py | 16 + ausglamr/settings.py | 160 +++++ ausglamr/urls.py | 55 ++ ausglamr/wsgi.py | 16 + blogs/__init__.py | 0 blogs/admin.py | 216 +++++++ blogs/apps.py | 9 + blogs/forms.py | 143 +++++ blogs/management/__init__.py | 0 blogs/management/commands/__init__.py | 1 + blogs/management/commands/announce.py | 19 + blogs/management/commands/check_feeds.py | 118 ++++ .../commands/queue_announcements.py | 49 ++ .../management/commands/send_weekly_email.py | 213 +++++++ blogs/migrations/0001_initial.py | 369 ++++++++++++ blogs/migrations/__init__.py | 0 blogs/models/__init__.py | 8 + blogs/models/blog.py | 128 ++++ blogs/models/event.py | 85 +++ blogs/models/group.py | 37 ++ blogs/models/newsletter.py | 41 ++ blogs/models/subscriber.py | 41 ++ blogs/models/utils.py | 84 +++ blogs/templates/blogs/confirm-register.html | 54 ++ blogs/templates/blogs/register.html | 20 + .../templates/blogs/submit-registration.html | 16 + blogs/templates/browse/blogs.html | 19 + blogs/templates/browse/cfp.html | 25 + blogs/templates/browse/events.html | 31 + blogs/templates/browse/groups.html | 20 + blogs/templates/browse/newsletters.html | 18 + blogs/templates/browse/tags.html | 63 ++ blogs/templates/confirm-email.html | 5 + blogs/templates/contact.html | 27 + blogs/templates/contribute.html | 54 ++ blogs/templates/events/cfp.html | 66 ++ blogs/templates/events/register.html | 19 + blogs/templates/help.html | 49 ++ blogs/templates/index.html | 36 ++ blogs/templates/layout.html | 65 ++ blogs/templates/messages.html | 7 + blogs/templates/register-group.html | 20 + blogs/templates/register-newsletter.html | 19 + blogs/templates/search.html | 63 ++ blogs/templates/subscribe-email.html | 20 + blogs/templates/subscribe.html | 51 ++ blogs/templates/thanks.html | 16 + blogs/templates/unsubscribe.html | 7 + blogs/templatetags/__init__.py | 0 blogs/templatetags/glamr_tags.py | 12 + blogs/tests/test_management.py | 66 ++ blogs/tests/test_models.py | 179 ++++++ blogs/tests/test_utilities.py | 8 + blogs/tests/test_views.py | 224 +++++++ blogs/utilities.py | 124 ++++ blogs/views/__init__.py | 28 + blogs/views/feeds.py | 73 +++ blogs/views/public.py | 570 ++++++++++++++++++ docker-compose.yml | 26 + glamr-dev | 78 +++ manage.py | 22 + requirements.txt | 11 + 69 files changed, 4139 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 admin.py create mode 100644 ausglamr/__init__.py create mode 100644 ausglamr/asgi.py create mode 100644 ausglamr/settings.py create mode 100644 ausglamr/urls.py create mode 100644 ausglamr/wsgi.py create mode 100644 blogs/__init__.py create mode 100644 blogs/admin.py create mode 100644 blogs/apps.py create mode 100644 blogs/forms.py create mode 100644 blogs/management/__init__.py create mode 100644 blogs/management/commands/__init__.py create mode 100644 blogs/management/commands/announce.py create mode 100644 blogs/management/commands/check_feeds.py create mode 100644 blogs/management/commands/queue_announcements.py create mode 100644 blogs/management/commands/send_weekly_email.py create mode 100644 blogs/migrations/0001_initial.py create mode 100644 blogs/migrations/__init__.py create mode 100644 blogs/models/__init__.py create mode 100644 blogs/models/blog.py create mode 100644 blogs/models/event.py create mode 100644 blogs/models/group.py create mode 100644 blogs/models/newsletter.py create mode 100644 blogs/models/subscriber.py create mode 100644 blogs/models/utils.py create mode 100644 blogs/templates/blogs/confirm-register.html create mode 100644 blogs/templates/blogs/register.html create mode 100644 blogs/templates/blogs/submit-registration.html create mode 100644 blogs/templates/browse/blogs.html create mode 100644 blogs/templates/browse/cfp.html create mode 100644 blogs/templates/browse/events.html create mode 100644 blogs/templates/browse/groups.html create mode 100644 blogs/templates/browse/newsletters.html create mode 100644 blogs/templates/browse/tags.html create mode 100644 blogs/templates/confirm-email.html create mode 100644 blogs/templates/contact.html create mode 100644 blogs/templates/contribute.html create mode 100644 blogs/templates/events/cfp.html create mode 100644 blogs/templates/events/register.html create mode 100644 blogs/templates/help.html create mode 100644 blogs/templates/index.html create mode 100644 blogs/templates/layout.html create mode 100644 blogs/templates/messages.html create mode 100644 blogs/templates/register-group.html create mode 100644 blogs/templates/register-newsletter.html create mode 100644 blogs/templates/search.html create mode 100644 blogs/templates/subscribe-email.html create mode 100644 blogs/templates/subscribe.html create mode 100644 blogs/templates/thanks.html create mode 100644 blogs/templates/unsubscribe.html create mode 100644 blogs/templatetags/__init__.py create mode 100644 blogs/templatetags/glamr_tags.py create mode 100644 blogs/tests/test_management.py create mode 100644 blogs/tests/test_models.py create mode 100644 blogs/tests/test_utilities.py create mode 100644 blogs/tests/test_views.py create mode 100644 blogs/utilities.py create mode 100644 blogs/views/__init__.py create mode 100644 blogs/views/feeds.py create mode 100644 blogs/views/public.py create mode 100644 docker-compose.yml create mode 100755 glamr-dev create mode 100755 manage.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d5bec16 --- /dev/null +++ b/.env.example @@ -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 " +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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c66948 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data +static \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..391b6a7 --- /dev/null +++ b/.pylintrc @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0bf5039 --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec8ed27 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..c6fe108 --- /dev/null +++ b/admin.py @@ -0,0 +1,2 @@ +from django.contrib import admin + diff --git a/ausglamr/__init__.py b/ausglamr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ausglamr/asgi.py b/ausglamr/asgi.py new file mode 100644 index 0000000..d3401ea --- /dev/null +++ b/ausglamr/asgi.py @@ -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() diff --git a/ausglamr/settings.py b/ausglamr/settings.py new file mode 100644 index 0000000..9337553 --- /dev/null +++ b/ausglamr/settings.py @@ -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") diff --git a/ausglamr/urls.py b/ausglamr/urls.py new file mode 100644 index 0000000..c067fa6 --- /dev/null +++ b/ausglamr/urls.py @@ -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/", 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//", + views.ConfirmEmail.as_view(), + name="confirm-subscribe-email", + ), + path( + "unsubscribe-email//", + views.UnsubscribeEmail.as_view(), + name="unsubscribe-email", + ), + path("feeds/blogs", views.ArticleFeed(), name="article-feed"), + path("feeds/events", views.EventFeed(), name="event-feed"), +] diff --git a/ausglamr/wsgi.py b/ausglamr/wsgi.py new file mode 100644 index 0000000..d1e8f84 --- /dev/null +++ b/ausglamr/wsgi.py @@ -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() diff --git a/blogs/__init__.py b/blogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blogs/admin.py b/blogs/admin.py new file mode 100644 index 0000000..9c150ef --- /dev/null +++ b/blogs/admin.py @@ -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"

{title} has been approved on AusGLAMR. Hooray!

" + + 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"\ +

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.

\ + " + + 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"\ +

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.

\ + " + + 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] diff --git a/blogs/apps.py b/blogs/apps.py new file mode 100644 index 0000000..db6c77b --- /dev/null +++ b/blogs/apps.py @@ -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" diff --git a/blogs/forms.py b/blogs/forms.py new file mode 100644 index 0000000..b42e6e3 --- /dev/null +++ b/blogs/forms.py @@ -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"] diff --git a/blogs/management/__init__.py b/blogs/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blogs/management/commands/__init__.py b/blogs/management/commands/__init__.py new file mode 100644 index 0000000..d9200ab --- /dev/null +++ b/blogs/management/commands/__init__.py @@ -0,0 +1 @@ +""" call these commands using 'python manage.py name_of_command' """ diff --git a/blogs/management/commands/announce.py b/blogs/management/commands/announce.py new file mode 100644 index 0000000..b7d6058 --- /dev/null +++ b/blogs/management/commands/announce.py @@ -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() diff --git a/blogs/management/commands/check_feeds.py b/blogs/management/commands/check_feeds.py new file mode 100644 index 0000000..84f5978 --- /dev/null +++ b/blogs/management/commands/check_feeds.py @@ -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())}") diff --git a/blogs/management/commands/queue_announcements.py b/blogs/management/commands/queue_announcements.py new file mode 100644 index 0000000..6f43dd6 --- /dev/null +++ b/blogs/management/commands/queue_announcements.py @@ -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() diff --git a/blogs/management/commands/send_weekly_email.py b/blogs/management/commands/send_weekly_email.py new file mode 100644 index 0000000..ee15256 --- /dev/null +++ b/blogs/management/commands/send_weekly_email.py @@ -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"

{blog.title}

" + author_string = ( + f"

{blog.author_name}

" if blog.author_name else "" + ) + description_string = ( + f"

{blog.description}

" + ) + + string_list = [title_string, author_string, description_string] + string = "".join(string_list) + + new_blogs = new_blogs + string + + if new_blogs != "": + new_blogs = ( + "

New Blogs

" + new_blogs + "
" + ) + + new_articles = "" + for post in articles: + title_string = f"

{post.title}

" + author_string = ( + f"

{post.author_name}

" if post.author_name else "" + ) + description_string = ( + f"

{post.description}

" + ) + + string_list = [title_string, author_string, description_string] + string = "".join(string_list) + + new_articles = new_articles + string + + if new_articles != "": + new_articles = ( + "

New Articles

" + + new_articles + + "
" + ) + + coming_events = "" + for event in events: + s_date = event.start_date + title_string = f"

{event.name}

" + date_string = ( + f"

{s_date:%a} {s_date.day} {s_date:%B} {s_date:%Y}

" + ) + description_string = ( + f"

{event.description}

" + ) + + string_list = [title_string, date_string, description_string] + string = "".join(string_list) + + coming_events = coming_events + string + + if coming_events != "": + coming_events = ( + "

Upcoming Events

" + + coming_events + + "
" + ) + + open_cfps = "" + for instance in cfps: + c_date = instance.closing_date + title_string = ( + f"

{instance.name}

" + ) + dates_string = f"

Closes:{c_date:%a} {c_date.day} {c_date:%B}

" + description_string = ( + f"

{instance.details}

" + ) + + string_list = [title_string, dates_string, description_string] + string = "".join(string_list) + + open_cfps = open_cfps + string + + if open_cfps != "": + open_cfps = ( + "

Open Calls

" + open_cfps + "
" + ) + + new_newsletters = "" + for instance in newsletters: + title_string = f"

{instance.name}

" + author_string = ( + f"

{instance.author}

" if instance.author else "" + ) + description_string = ( + f"

{instance.description}

" + ) + string_list = [title_string, author_string, description_string] + string = "".join(string_list) + + new_newsletters = new_newsletters + string + + if new_newsletters != "": + new_newsletters = ( + "

New Newsletters

" + + new_newsletters + + "
" + ) + + new_groups = "" + for instance in groups: + group_type = GroupType(instance.type).label + title_string = f"

{instance.name}

" + register_string = f"

Register to join this {group_type}

" + description_string = ( + f"

{instance.description}

" + ) + string_list = [title_string, register_string, description_string] + string = "".join(string_list) + + new_groups = new_groups + string + + if new_groups != "": + new_groups = ( + "

New Groups

" + new_groups + "
" + ) + + # 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 = "" + footer = f"

This email was sent to {subscriber.email} because you subscribed to email updates from Aus GLAMR.

You can unsubscribe at any time.

" + end = "" + parts = [start, body, footer, end] + message = "".join(parts) + + send_email(subject, message, subscriber.email) + + print(f"Weekly emails completed {timezone.now()}") diff --git a/blogs/migrations/0001_initial.py b/blogs/migrations/0001_initial.py new file mode 100644 index 0000000..bd25004 --- /dev/null +++ b/blogs/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/blogs/migrations/__init__.py b/blogs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blogs/models/__init__.py b/blogs/models/__init__.py new file mode 100644 index 0000000..00db077 --- /dev/null +++ b/blogs/models/__init__.py @@ -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 diff --git a/blogs/models/blog.py b/blogs/models/blog.py new file mode 100644 index 0000000..471babd --- /dev/null +++ b/blogs/models/blog.py @@ -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 diff --git a/blogs/models/event.py b/blogs/models/event.py new file mode 100644 index 0000000..ebb8c69 --- /dev/null +++ b/blogs/models/event.py @@ -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) diff --git a/blogs/models/group.py b/blogs/models/group.py new file mode 100644 index 0000000..daaabd1 --- /dev/null +++ b/blogs/models/group.py @@ -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) diff --git a/blogs/models/newsletter.py b/blogs/models/newsletter.py new file mode 100644 index 0000000..a7c388e --- /dev/null +++ b/blogs/models/newsletter.py @@ -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) diff --git a/blogs/models/subscriber.py b/blogs/models/subscriber.py new file mode 100644 index 0000000..31d4f9e --- /dev/null +++ b/blogs/models/subscriber.py @@ -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 = "" + body = f"

Please confirm your email address to receive weekly updates.

" + footer = ( + f"

You can unsubscribe at any time.

" + ) + end = "" + + parts = [start, body, footer, end] + message = "".join(parts) + + send_email(subject, message, recipient) diff --git a/blogs/models/utils.py b/blogs/models/utils.py new file mode 100644 index 0000000..14c58e4 --- /dev/null +++ b/blogs/models/utils.py @@ -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) diff --git a/blogs/templates/blogs/confirm-register.html b/blogs/templates/blogs/confirm-register.html new file mode 100644 index 0000000..9e2ab82 --- /dev/null +++ b/blogs/templates/blogs/confirm-register.html @@ -0,0 +1,54 @@ +{% extends "layout.html" %} + +{% block content %} + + +{% if error %} +
+ {{ error }} +
+{% endif %} +
+ {% csrf_token %} + +
+
+ + + + + + + + + + + + {{ form.url.errors }} + + {{ form.url }} + + + + + {{ form.category.errors }} + + {{ form.category }} + + {{ form.activitypub_account_name.errors }} + + {{ form.activitypub_account_name }} + + {{ form.owner_email.errors }} + + {{ form.owner_email }} + + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/blogs/register.html b/blogs/templates/blogs/register.html new file mode 100644 index 0000000..63caf7f --- /dev/null +++ b/blogs/templates/blogs/register.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block content %} + + + +
+ {% csrf_token %} +
+
+ {{ form }} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/blogs/submit-registration.html b/blogs/templates/blogs/submit-registration.html new file mode 100644 index 0000000..970121d --- /dev/null +++ b/blogs/templates/blogs/submit-registration.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block content %} + +
+ {% csrf_token %} + +
+
+ {{ form }} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/browse/blogs.html b/blogs/templates/browse/blogs.html new file mode 100644 index 0000000..d82a933 --- /dev/null +++ b/blogs/templates/browse/blogs.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block content %} +
+
Title
+
Author
+
Category
+
+
+ {% for blog in blogs %} +
+ +
{{blog.author_name}}
+
{{blog.category_name}}
+
+
+ {% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/browse/cfp.html b/blogs/templates/browse/cfp.html new file mode 100644 index 0000000..47b4f72 --- /dev/null +++ b/blogs/templates/browse/cfp.html @@ -0,0 +1,25 @@ +{% extends "layout.html" %} + +{% block content %} + +
+ Details + Event + Closes +
+
+ + {% for cfp in cfps %} +
+
+

{{cfp.name}}

+

{{cfp.details}}

+
+ {{cfp.event.name}} + {{cfp.closing_date}} +
+
+ {% endfor %} + + +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/browse/events.html b/blogs/templates/browse/events.html new file mode 100644 index 0000000..d596201 --- /dev/null +++ b/blogs/templates/browse/events.html @@ -0,0 +1,31 @@ +{% extends "layout.html" %} + +{% block content %} + +
+ Name + Category + Start Date + Description +
+
+ + + {% for con in cons %} +
{{con.call_for_papers.closing_date}}
+
+ {{con.name}} + {{con.category_name}} + {{con.start_date}} +
+
{{con.description}}
+ {% if con.call_for_papers %} + + {% endif %} +
+
+
+ {% endfor %} + + +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/browse/groups.html b/blogs/templates/browse/groups.html new file mode 100644 index 0000000..58de2a3 --- /dev/null +++ b/blogs/templates/browse/groups.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block content %} +
+ Name + Category + Description + Registration Link +
+
+ {% for group in groups %} +
+ {{group.name}} + {{group.category_name}} + {{group.description}} + Join the {{group.reg_type}} +
+
+ {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/browse/newsletters.html b/blogs/templates/browse/newsletters.html new file mode 100644 index 0000000..3008ebf --- /dev/null +++ b/blogs/templates/browse/newsletters.html @@ -0,0 +1,18 @@ +{% extends "layout.html" %} + +{% block content %} +
+ Title + Author + Category +
+
+ {% for pub in news %} +
+ {{pub.name}} + {{pub.author}} + {{pub.category_name}} +
+
+ {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/browse/tags.html b/blogs/templates/browse/tags.html new file mode 100644 index 0000000..e7fce3e --- /dev/null +++ b/blogs/templates/browse/tags.html @@ -0,0 +1,63 @@ +{% extends "layout.html" %} + +{% block content %} + {% if items %} + {% for item in items %} +
+
+

{% if item.title %} {{ item.title }} {% else %} {{item.name}} {% endif %}

+

+ {% if item.author_name or item.author %} + {% if item.author_name%} {{ item.author_name }} {% else %} {{ item.author_name }} {% endif %} | + {% endif %} + {% if item.blog %} + {{ item.blog.title }} + {% elif item.description %} +

{{ item.description }}

+ {% elif item.details %} +

{{ item.details }}

+ {% endif %} +

+ {% if item.tags %} + {% for tag in item.tags.all %} + {{ tag.name }} + {% endfor %} + {% elif item.category_name %} + {{item.category_name}} + {% endif %} +
+
+ {% endfor %} + +
+ + {% if items.has_previous %} + « first | + previous + {% else %} + « first | previous + {% endif %} + + + Page {{ items.number }} of {{ items.paginator.num_pages }} + + + {% if items.has_next %} + next | + last » + {% else %} + next | last » + {% endif %} + +
+ {% elif query %} +

No items found for "{{ query }}"

+ {% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/confirm-email.html b/blogs/templates/confirm-email.html new file mode 100644 index 0000000..6a8dcf8 --- /dev/null +++ b/blogs/templates/confirm-email.html @@ -0,0 +1,5 @@ +{% extends "layout.html" %} + +{% block content %} +

Your email address has been confirmed! You can opt-out at any time by clicking the link at the bottom of the weekly emails.

+{% endblock %} \ No newline at end of file diff --git a/blogs/templates/contact.html b/blogs/templates/contact.html new file mode 100644 index 0000000..5c972ec --- /dev/null +++ b/blogs/templates/contact.html @@ -0,0 +1,27 @@ +{% extends "layout.html" %} + +{% block content %} + +
+ {% csrf_token %} + +
+
+ + {% if errors %} {{ form.from_email.errors }} {% endif %} + {{form.from_email}} + + {% if errors %} {{ form.subject.errors }} {% endif %} + {{form.subject}} + {% if errors %} {{ form.message.errors }} {% endif %} + + + +
+
+
+{% endblock %} + diff --git a/blogs/templates/contribute.html b/blogs/templates/contribute.html new file mode 100644 index 0000000..9b1e51e --- /dev/null +++ b/blogs/templates/contribute.html @@ -0,0 +1,54 @@ +{% extends "layout.html" %} + +
+ {% block intro %}{% endblock %} + {% block title %} +

{{ title }}

+ {% endblock %} +
+ {% block content %} + + + +
+

You can contribute to Aus GLAMR by registering a blog, newsletter, event, or discussion group. Creators or content should be Australasian-based.

+
+ +
+ +
+

Register a blog

+

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.

+ Register blog +
+ +
+

Register a group

+

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.

+ Register group +
+ +
+

Register an event

+

Conference, convention, seminar, workshop, talk, meet-up... +
Whatever you call it, you know what we mean.

+ Register event +
+ +
+

Register a newsletter

+

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.

+ Register newsletter +
+ + + +
+

Register or update a Call for Papers

+

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.

+ Register CFP +
+ +
+ {% endblock %} +
diff --git a/blogs/templates/events/cfp.html b/blogs/templates/events/cfp.html new file mode 100644 index 0000000..2800176 --- /dev/null +++ b/blogs/templates/events/cfp.html @@ -0,0 +1,66 @@ +{% extends "layout.html" %} + +{% block intro %} +{% if conf_name %} +
+ {{ conf_name }} has been registered! +
+{% endif %} + +{% endblock %} + +{% block title %} +{% if conf_name %} +

Register a Call for Papers for {{ conf_name }}

+{% else %} +

Register a Call for Papers

+{% endif %} +{% endblock %} + +{% block content %} + + +
+ {% csrf_token %} +
+
+ {% if conf_name %} + + {% else %} + + {% if errors %} {{ form.event.errors }} {% endif %} + {{ form.event }} + {% endif %} + + + {% if errors %} {{ form.name.errors }} {% endif %} +

e.g. "Call for Papers", "Request for submissions" etc

+ + {% if errors %} {{ form.details.errors }} {% endif %} + + + +
+ + {% if errors %} {{ form.opening_date.errors }} {% endif %} + + {% if errors %} {{ form.opening_date.errors }} {% endif %} + {{ form.opening_date }} + + + + {% if errors %} {{ form.closing_date.errors }} {% endif %} + {{ form.closing_date }} + +
+ + + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/events/register.html b/blogs/templates/events/register.html new file mode 100644 index 0000000..1d969c4 --- /dev/null +++ b/blogs/templates/events/register.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block content %} + + +
+ {% csrf_token %} +
+
+ {{ form }} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/help.html b/blogs/templates/help.html new file mode 100644 index 0000000..50dfc0d --- /dev/null +++ b/blogs/templates/help.html @@ -0,0 +1,49 @@ +{% extends "layout.html" %} + +{% block content %} + +
+ {% with 'contributing' as anchor %} + {% url 'help' as the_url %} +

Adding something to the site

🔗 +

If you want to add a blog, event, group, or newsletter, head to Contribute.

+ {% endwith %} + + {% with 'updating' as anchor %} + {% url 'help' as the_url %} +

Updating or removing a listing

🔗 +

Made a mistake or have more information to add? Blog got hacked? Changed careers? Just fill in the contact form.

+ {% endwith %} + + {% with 'opting-out' as anchor %} + {% url 'help' as the_url %} +

Opting out for a blog post

🔗 +

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.

+
    +
  • notglam
  • +
  • notglamr
  • +
  • notausglamr
  • +
  • notausglamblogs
  • +
+ {% endwith %} + + {% with 'aus-glam-blogs' as anchor %} + {% url 'help' as the_url %} +

What happened to Aus GLAM Blogs?

🔗 +

Aus GLAM Blogs was focussed on blogs specifically. Aus GLAMR 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.

+ {% endwith %} + + {% with 'pocket' as anchor %} + {% url 'help' as the_url %} +

What happened to the Pocket integration?

🔗 +

The integration with Pocket from Aus GLAM Blogs is no longer available. Please consider using the RSS feed or email subscription instead.

+ {% endwith %} + + {% with 'activitypub' as anchor %} + {% url 'help' as the_url %} +

Activitypub account name!!???

🔗 +

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 Aus GLAM Space.

+ {% endwith %} +
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/index.html b/blogs/templates/index.html new file mode 100644 index 0000000..d13ae66 --- /dev/null +++ b/blogs/templates/index.html @@ -0,0 +1,36 @@ +{% extends "layout.html" %} + +{% block intro %} +
+

Aus GLAMR 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!

+

Browse one of the lists, search by keywords, or contribute by registering your blog, event, group or newsletter.

+

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

+

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.

+
+{% endblock %} + +{% block content %} + {% for article in latest %} +
+
+

{{ article.title }}

+

+ {% if article.author_name %} + {{ article.author_name }} | + {% endif %} + {{ article.blog.title }} +

+ {% for tag in article.tags.all %} + {{ tag.name }} + {% endfor %} +
+ {% if article.description %} +
+ {% autoescape off %} +

{{ article.description|truncatewords_html:60 }}

+ {% endautoescape %} +
+ {% endif %} +
+ {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/layout.html b/blogs/templates/layout.html new file mode 100644 index 0000000..7388242 --- /dev/null +++ b/blogs/templates/layout.html @@ -0,0 +1,65 @@ +{% load static %} +{% load glamr_tags %} + + + + + + + {{ title }} + + + + + +
+ + +
+
+ {% site_messages %} +
+ {% block container %} +
+ {% block intro %}{% endblock %} + {% block title %} +

{{ title }}

+ {% endblock %} +
+ {% block content %}{% endblock %} +
+ {% endblock %} + +
+ + + + \ No newline at end of file diff --git a/blogs/templates/messages.html b/blogs/templates/messages.html new file mode 100644 index 0000000..03c020a --- /dev/null +++ b/blogs/templates/messages.html @@ -0,0 +1,7 @@ +
+ {% for msg in messages %} +
+ {{ msg.message }} +
+ {% endfor %} +
\ No newline at end of file diff --git a/blogs/templates/register-group.html b/blogs/templates/register-group.html new file mode 100644 index 0000000..5b11ec0 --- /dev/null +++ b/blogs/templates/register-group.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block content %} + + +
+ {% csrf_token %} + +
+
+ {{ form }} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/register-newsletter.html b/blogs/templates/register-newsletter.html new file mode 100644 index 0000000..b8bc581 --- /dev/null +++ b/blogs/templates/register-newsletter.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block content %} + + +
+ {% csrf_token %} +
+
+ {{ form }} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/search.html b/blogs/templates/search.html new file mode 100644 index 0000000..38d19a7 --- /dev/null +++ b/blogs/templates/search.html @@ -0,0 +1,63 @@ +{% extends "layout.html" %} + +{% block content %} + +
+ + + +
+ + {% if items %} + {% for item in items %} +
+
+

{% if item.title %} {{ item.title }} {% else %} {{item.name}} {% endif %}

+

+ {% if item.author_name or item.author %} + {% if item.author_name%} {{ item.author_name }} {% else %} {{ item.author_name }} {% endif %} | + {% endif %} + {% if item.blog %} + {{ item.blog.title }} + {% elif item.description %} +

{{ item.description }}

+ {% elif item.details %} +

{{ item.details }}

+ {% endif %} +

+ {% if item.tags %} + {% for tag in item.tags.all %} + {{ tag.name }} + {% endfor %} + {% elif item.category_name %} + {{item.category_name}} + {% endif %} +
+
+ {% endfor %} + +
+ + {% if items.has_previous %} + « first | + previous + {% else %} + « first | previous + {% endif %} + + + Page {{ items.number }} of {{ items.paginator.num_pages }} + + + {% if items.has_next %} + next | + last » + {% else %} + next | last » + {% endif %} + +
+ {% elif query %} +

No items found for "{{ query }}"

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/subscribe-email.html b/blogs/templates/subscribe-email.html new file mode 100644 index 0000000..b2aa0b9 --- /dev/null +++ b/blogs/templates/subscribe-email.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block content %} + + +
+ {% csrf_token %} + +
+
+ {{ form }} + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/subscribe.html b/blogs/templates/subscribe.html new file mode 100644 index 0000000..b4d9223 --- /dev/null +++ b/blogs/templates/subscribe.html @@ -0,0 +1,51 @@ +{% extends "layout.html" %} + +{% block container %} +
+ {% block intro %}{% endblock %} + {% block title %} +

{{ title }}

+ {% endblock %} +
+ {% block content %} + + {% endblock %} +
+{% endblock %} \ No newline at end of file diff --git a/blogs/templates/thanks.html b/blogs/templates/thanks.html new file mode 100644 index 0000000..9c8776e --- /dev/null +++ b/blogs/templates/thanks.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block content %} + +{% if register_type == "message" %} +

Thanks for getting in touch!

+{% else %} +

Thankyou for registering your {{ register_type }}.

+ + {% if register_type == "email address" %} +

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.

+ {% else %} +

Your {{ register_type }} registration will be reviewed before being added to Aus GLAMR.

+ {% endif %} +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/blogs/templates/unsubscribe.html b/blogs/templates/unsubscribe.html new file mode 100644 index 0000000..b269d9c --- /dev/null +++ b/blogs/templates/unsubscribe.html @@ -0,0 +1,7 @@ +{% extends "layout.html" %} + +{% block content %} + +

You have been unsubscribed from weekly emails.

+ +{% endblock %} \ No newline at end of file diff --git a/blogs/templatetags/__init__.py b/blogs/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blogs/templatetags/glamr_tags.py b/blogs/templatetags/glamr_tags.py new file mode 100644 index 0000000..13e157d --- /dev/null +++ b/blogs/templatetags/glamr_tags.py @@ -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()} diff --git a/blogs/tests/test_management.py b/blogs/tests/test_management.py new file mode 100644 index 0000000..1957cb2 --- /dev/null +++ b/blogs/tests/test_management.py @@ -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) diff --git a/blogs/tests/test_models.py b/blogs/tests/test_models.py new file mode 100644 index 0000000..bc8418f --- /dev/null +++ b/blogs/tests/test_models.py @@ -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")) diff --git a/blogs/tests/test_utilities.py b/blogs/tests/test_utilities.py new file mode 100644 index 0000000..9836cef --- /dev/null +++ b/blogs/tests/test_utilities.py @@ -0,0 +1,8 @@ +"""test utility functions""" + +# TODO: +""" +get_feed_info +get_blog_info +get_webfinger_subscribe_uri +""" diff --git a/blogs/tests/test_views.py b/blogs/tests/test_views.py new file mode 100644 index 0000000..9054534 --- /dev/null +++ b/blogs/tests/test_views.py @@ -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 diff --git a/blogs/utilities.py b/blogs/utilities.py new file mode 100644 index 0000000..caceaa9 --- /dev/null +++ b/blogs/utilities.py @@ -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() diff --git a/blogs/views/__init__.py b/blogs/views/__init__.py new file mode 100644 index 0000000..465dd37 --- /dev/null +++ b/blogs/views/__init__.py @@ -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 diff --git a/blogs/views/feeds.py b/blogs/views/feeds.py new file mode 100644 index 0000000..7fded5d --- /dev/null +++ b/blogs/views/feeds.py @@ -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)] diff --git a/blogs/views/public.py b/blogs/views/public.py new file mode 100644 index 0000000..df4061d --- /dev/null +++ b/blogs/views/public.py @@ -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"\ +

A new {instance} has been registered:

\ + {obj.title if hasattr(obj, 'title') else obj.name}

\ +

To approve or reject visit {settings.DOMAIN}/admin

\ + " + + 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"

{message}

" + + msg = EmailMessage( + f"Message via Aus GLAMR: {subject}", + html_message, + f"{from_email}", + [settings.ADMIN_EMAIL], + ) + msg.content_subtype = "html" + msg.send() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..989ee4c --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/glamr-dev b/glamr-dev new file mode 100755 index 0000000..30cce8c --- /dev/null +++ b/glamr-dev @@ -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 \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..6618d02 --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..601769d --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file