Skip to content

Commit

Permalink
Make unsubscribe work
Browse files Browse the repository at this point in the history
  • Loading branch information
blopker committed Oct 6, 2023
1 parent fd89102 commit ba35d50
Show file tree
Hide file tree
Showing 9 changed files with 159 additions and 71 deletions.
40 changes: 22 additions & 18 deletions totem/circles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ def subscribe(self, user):
def unsubscribe(self, user):
return self.subscribed.remove(user)

def subscribe_token(self, user):
return basic_hash(f"{self.slug}-{user.email}-{user.api_key}")


class CircleEvent(MarkdownMixin, SluggedModel):
open = models.BooleanField(default=True, help_text="Is this Circle for more attendees?")
Expand All @@ -109,16 +112,21 @@ def seats_left(self):
def attendee_list(self):
return ", ".join([str(attendee) for attendee in self.attendees.all()])

def can_attend(self):
if not self.open:
raise CircleEventException("Circle is not open")
if self.cancelled:
raise CircleEventException("Circle is cancelled")
if self.started():
raise CircleEventException("Circle has already started")
if self.seats_left() <= 0:
raise CircleEventException("No seats left")
return True
def can_attend(self, silent=False):
try:
if not self.open:
raise CircleEventException("Circle is not open")
if self.cancelled:
raise CircleEventException("Circle is cancelled")
if self.started():
raise CircleEventException("Circle has already started")
if self.seats_left() <= 0:
raise CircleEventException("No seats left")
return True
except CircleEventException as e:
if silent:
return False
raise e

def add_attendee(self, user):
if user.is_staff or self.can_attend():
Expand Down Expand Up @@ -160,21 +168,17 @@ def notify(self, force=False):
self.notified = True
self.save()
for user in self.attendees.all():
start = self.start.astimezone(user.timezone)
send_notify_circle_starting(
self.circle.title, start, reverse("circles:join", kwargs={"event_slug": self.slug}), user.email
)
send_notify_circle_starting(self, user)

def advertise(self, force=False):
# Notify users who are attending that the circle is about to start
# Notify users who are subscribed that a new event is available.
if force is False and self.advertised:
return
self.advertised = True
self.save()
for user in self.circle.subscribed.all():
if self.can_attend() and user not in self.attendees.all():
start = self.start.astimezone(user.timezone)
send_notify_circle_advertisement(self.circle.title, start, self.get_absolute_url(), user.email)
if self.can_attend(silent=True) and user not in self.attendees.all():
send_notify_circle_advertisement(self, user)

def __str__(self):
return f"CircleEvent: {self.start}"
Expand Down
19 changes: 19 additions & 0 deletions totem/circles/templates/circles/subscribed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load static %}
{% load avatar %}
{# djlint:off #}
{% block title %}Circles - {{ object.title }}{% endblock title %}
{# djlint:on #}
{% block content %}
<div class="h-10"></div>
<div class="section">
<div class="bg-white rounded-3xl w-1/3 m-auto p-10">
{% if unsubscribed %}
You are now <strong>unsubscribed</strong> from
{% else %}
You are <strong>subscribed</strong> to
{% endif %}
the <a class="a" href="{% url "circles:detail" slug=circle.slug %}">{{ circle.title }}</a> circle.
</div>
</div>
{% endblock content %}
36 changes: 36 additions & 0 deletions totem/circles/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime

from django.test import TestCase
from django.urls import reverse
from django.utils import timezone

Expand Down Expand Up @@ -90,3 +91,38 @@ def test_join_attending(self, client, db):
assert response.status_code == 302
assert "example" in response.url
assert user in event.joined.all()


class AnonSubscribeViewTest(TestCase):
def setUp(self):
self.user = UserFactory()
self.circle = CircleFactory()
self.token = self.circle.subscribe_token(self.user)

def test_anon_subscribe(self):
url = reverse("circles:subscribe", args=[self.circle.slug])
response = self.client.get(f"{url}?user={self.user.slug}&token={self.token}")
assert response.status_code == 200
self.assertTemplateUsed(response, "circles/subscribed.html")
self.assertTrue(self.user in self.circle.subscribed.all())

def test_anon_subscribe_wrong_token(self):
url = reverse("circles:subscribe", args=[self.circle.slug])
response = self.client.get(f"{url}?user={self.user.slug}&token=wrong-token")
assert response.status_code == 404
self.assertFalse(self.user in self.circle.subscribed.all())

def test_anon_subscribe_no_token(self):
url = reverse("circles:subscribe", args=[self.circle.slug])
response = self.client.get(f"{url}?user={self.user.slug}")
assert response.status_code == 200
self.assertTemplateUsed(response, "circles/subscribed.html")
self.assertFalse(self.user in self.circle.subscribed.all())

def test_anon_subscribe_unsubscribe(self):
url = reverse("circles:subscribe", args=[self.circle.slug])
response = self.client.get(f"{url}?user={self.user.slug}&token={self.token}&action=unsubscribe")
assert response.status_code == 200
self.assertTemplateUsed(response, "circles/subscribed.html")
self.assertTrue(response.context["unsubscribed"])
self.assertFalse(self.user in self.circle.subscribed.all())
40 changes: 33 additions & 7 deletions totem/circles/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.http import Http404
from django.http import Http404, HttpRequest
from django.shortcuts import redirect, render
from django.utils import timezone

Expand Down Expand Up @@ -162,16 +162,42 @@ def join(request, event_slug):
return redirect("circles:event_detail", event_slug=event.slug)


@login_required
def subscribe(request, slug):
if request.method != "POST":
raise Http404
def subscribe(request: HttpRequest, slug: str):
circle = _get_circle(slug)
user = request.user

if request.GET.get("token"):
return _token_subscribe(request, circle)

if request.method != "POST":
return render(request, "circles/subscribed.html", {"circle": circle})

return_url = request.POST.get("return_url")
if request.POST.get("action") == "unsubscribe":
circle.unsubscribe(request.user)
circle.unsubscribe(user)
else:
circle.subscribe(request.user)
circle.subscribe(user)
if return_url:
return redirect(return_url)
return redirect("circles:detail", slug=slug)


def _token_subscribe(request: HttpRequest, circle: Circle):
user_slug = request.GET.get("user")
sent_token = request.GET.get("token")

if not user_slug or not sent_token:
raise Http404

user = User.objects.get(slug=user_slug)
token = circle.subscribe_token(user)

if sent_token != token:
raise Http404

if request.GET.get("action") == "unsubscribe":
circle.unsubscribe(user)
return render(request, "circles/subscribed.html", {"circle": circle, "unsubscribed": True})
else:
circle.subscribe(user)
return render(request, "circles/subscribed.html", {"circle": circle})
34 changes: 22 additions & 12 deletions totem/email/emails.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from __future__ import annotations

import urllib.parse
from datetime import datetime
from typing import TYPE_CHECKING

from django.conf import settings
from django.urls import reverse

if TYPE_CHECKING:
from totem.circles.models import CircleEvent
from totem.users.models import User

from .utils import send_template_mail

Expand Down Expand Up @@ -40,29 +47,32 @@ def send_change_email(old_email: str, new_email: str, login_url: str):
)


def send_notify_circle_starting(circle_title: str, start_time: datetime, circle_url: str, attendee_email: str):
def send_notify_circle_starting(event: CircleEvent, user: User):
# 06:56 PM EDT on Friday, August 25
formatted_time = start_time.strftime("%I:%M %p %Z on %A, %B %d")
start = event.start.astimezone(user.timezone).strftime("%I:%M %p %Z on %A, %B %d")
_send_button_email(
recipient=attendee_email,
recipient=user.email,
subject="Your Circle is starting soon!",
message=f"Your Circle, {circle_title}, is starting at {formatted_time}. \
message=f"Your Circle, {event.circle.title}, is starting at {start}. \
Click the button below to join the Circle. If you are more than 5 minutes late, you may not be allowed to participate.",
button_text="Join Circle",
link=circle_url,
link=user.get_login_url(after_login_url=event.get_absolute_url()),
)


def send_notify_circle_advertisement(circle_title: str, start_time: datetime, event_url: str, attendee_email: str):
def send_notify_circle_advertisement(event: CircleEvent, user: User):
# 06:56 PM EDT on Friday, August 25
formatted_time = start_time.strftime("%I:%M %p %Z on %A, %B %d")
start = event.start.astimezone(user.timezone).strftime("%I:%M %p %Z on %A, %B %d")
unsubscribe_url = make_email_url(reverse("circles:subscribe", kwargs={"slug": event.circle.slug}))
unsubscribe_url += f"?user={user.slug}&token={event.circle.subscribe_token(user)}&action=unsubscribe"
_send_button_email(
recipient=attendee_email,
recipient=user.email,
subject="Join an upcoming Circle!",
message=f"A session for a Circle you are subscribed to, {circle_title}, is coming up at {formatted_time}. \
Click the button below to reserve a spot before this one fills up.",
message=f"A session for a Circle you are subscribed to, {event.circle.title}, is coming up at {start}. \
Click the button below to reserve a spot before this one fills up. If you no longer wish to get notifications about this Circle, \
you can unsubscribe here: {unsubscribe_url}",
button_text="Reserve a spot",
link=event_url,
link=event.get_absolute_url(),
)


Expand Down
6 changes: 0 additions & 6 deletions totem/users/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,3 @@
class UsersConfig(AppConfig):
name = "totem.users"
verbose_name = _("Users")

def ready(self):
try:
import totem.users.signals # noqa: F401
except ImportError:
pass
12 changes: 12 additions & 0 deletions totem/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ def get_absolute_url(self) -> str:
"""
return reverse("users:detail", kwargs={"slug": self.slug})

def get_login_url(self, after_login_url: str | None, mobile: bool = False) -> str:
from sesame.utils import get_query_string

if not after_login_url or after_login_url.startswith("http"):
after_login_url = reverse("users:redirect")

url = reverse("magic-login")

url += get_query_string(self)
url += "&next=" + after_login_url
return url

def get_keeper_url(self):
# hack until we can connect keepers to user profiles
keepers = {
Expand Down
34 changes: 6 additions & 28 deletions totem/users/views.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
from django import forms
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth import login as django_login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ObjectDoesNotExist
from django.shortcuts import redirect, render
from django.urls import reverse, reverse_lazy
from django.views.generic import DetailView, FormView
from sesame.utils import get_query_string
from sesame.views import LoginView as SesameLoginView

from totem.email import emails
from totem.utils.slack import notify_slack

from .forms import LoginForm

User = get_user_model()
from .models import User


class UserDetailView(LoginRequiredMixin, DetailView):
Expand Down Expand Up @@ -45,7 +42,7 @@ def user_update_view(request, *args, **kwargs):
message = "Profile successfully updated."
new_email = form.cleaned_data["email"]
if old_email != new_email:
login_url = get_login_url(user, after_login_url=None, mobile=False)
login_url = user.get_login_url(after_login_url=None, mobile=False)
user.verified = False
emails.send_change_email(old_email, new_email, login_url)
message = f"Email successfully updated to {new_email}. Please check your inbox to confirm."
Expand Down Expand Up @@ -99,29 +96,10 @@ def form_valid(self, form):
return super().form_valid(form)


def email_returning_user(user, url):
emails.send_returning_login_email(user.email, url)


def email_new_user(user, url):
emails.send_new_login_email(user.email, url)


def _notify_slack():
notify_slack("Signup: A new person has signed up for ✨Totem✨!")


def get_login_url(user, after_login_url: str | None, mobile: bool) -> str:
if not after_login_url or after_login_url.startswith("http"):
after_login_url = reverse("users:redirect")

url = reverse("magic-login")

url += get_query_string(user)
url += "&next=" + after_login_url
return url


def login(email: str, request, after_login_url: str | None = None, mobile: bool = False) -> bool:
"""Login a user by sending them a login link via email. If it's a new user, log them in automatically and send them
a welcome email.
Expand All @@ -132,14 +110,14 @@ def login(email: str, request, after_login_url: str | None = None, mobile: bool
"""
user, created = User.objects.get_or_create(email=email)

url = get_login_url(user, after_login_url, mobile)
url = user.get_login_url(after_login_url, mobile) # type: ignore

if created:
django_login(request, user, backend="django.contrib.auth.backends.ModelBackend")
email_new_user(user, url)
emails.send_new_login_email(user.email, url)
_notify_slack()
else:
email_returning_user(user, url)
emails.send_returning_login_email(user.email, url)

user.identify() # type: ignore
return created
Expand Down Expand Up @@ -176,7 +154,7 @@ def user_profile_info_view(request):
message = "Profile successfully updated."
new_email = form.cleaned_data["email"]
if old_email != new_email:
login_url = get_login_url(user, after_login_url=None, mobile=False)
login_url = user.get_login_url(after_login_url=None, mobile=False)
user.verified = False
emails.send_change_email(old_email, new_email, login_url)
message = f"Email successfully updated to {new_email}. Please check your inbox to confirm."
Expand Down
9 changes: 9 additions & 0 deletions totem/utils/hash.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import hashlib
import hmac as _hmac

from django.conf import settings


def basic_hash(data: str, as_int: bool = False) -> str | int:
Expand All @@ -7,3 +10,9 @@ def basic_hash(data: str, as_int: bool = False) -> str | int:
if as_int:
return int(h, 16)
return h


def hmac(data: str, key: str) -> str:
"""Returns a HMAC-SHA256 hash of the data using the key."""
key = key or settings.SECRET_KEY
return _hmac.new(key.encode("utf-8"), data.encode("utf-8"), hashlib.sha256).hexdigest()

0 comments on commit ba35d50

Please sign in to comment.