Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
418 changes: 418 additions & 0 deletions chores/core.py

Large diffs are not rendered by default.

Empty file added chores/management/__init__.py
Empty file.
64 changes: 64 additions & 0 deletions chores/management/commands/send_reminders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import logging
from collections import namedtuple
from datetime import datetime

from django.core.management.base import BaseCommand

from ...core import ChoreEventsLogic, Time
from ...models import Chore, ChoreVolunteer

logger = logging.getLogger(__name__)


NudgesParams = namedtuple(
"NudgesParams", "volunteers now urls message_users_seen_no_later_than_days"
)


class Clock(object):
@staticmethod
def now():
return Time.from_datetime(datetime.utcnow())


class Command(BaseCommand):
help = "Send reminders for chores"

def handle(self, *args, **kwargs):
chores = Chore.objects.all()
logger.debug("handle", len(chores))
now = Clock.now()

# Configuration lifted from aggregator
# TODO: Make these configurable
self.chores_warnings_check_window_in_hours = 2
self.chores_message_users_seen_no_later_than_days = 14

for event in ChoreEventsLogic(chores).iter_events_with_reminders_from_to(
now.add(-self.chores_warnings_check_window_in_hours, "hours"), now
):
volunteers = ChoreVolunteer.objects.all().filter(chore=event.chore.chore_id)
logger.debug("send_reminders.handle.volunteers", len(volunteers))
params = NudgesParams(
volunteers,
now,
Urls(),
self.chores_message_users_seen_no_later_than_days,
)
for nudge in event.iter_nudges(params):
logger.info("Processing Chore nudge: {0}".format(nudge))
logger.debug("nudge.get_string_key", nudge.get_string_key())
nudge.send()

self.stdout.write("Sending notifications")


class Urls(object):
def notification_settings(self):
return "https://mijn.makerspaceleiden.nl/notifications/settings"

def space_state(self):
return "https://mijn.makerspaceleiden.nl/space_state"

def chores(self):
return "https://mijn.makerspaceleiden.nl/chores/"
12 changes: 12 additions & 0 deletions chores/messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class VolunteeringReminderNotification(object):
def __init__(self, user, event):
self.user = user
self.event = event

def get_text(self):
chore_description = self.event.chore.description
chore_event_ts_human = self.event.ts.strftime("%a %d/%m/%Y %H:%M")
return f"{self.user.first_name}, here's a friendly reminder that you signed up for {chore_description} at {chore_event_ts_human}. Don't forget!"

def get_subject_for_email(self):
return "Volunteering reminder"
53 changes: 53 additions & 0 deletions chores/migrations/0005_chorenotification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 4.2.21 on 2025-07-11 18:11

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("chores", "0004_chore_wiki_url_historicalchore_wiki_url"),
]

operations = [
migrations.CreateModel(
name="ChoreNotification",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("event_key", models.CharField(max_length=128, unique=True)),
(
"recipient_other",
models.EmailField(blank=True, max_length=254, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"chore",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="chores.chore",
),
),
(
"recipient_user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="chore_notifications",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
24 changes: 24 additions & 0 deletions chores/migrations/0006_alter_chore_configuration_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Generated by Django 4.2.21 on 2025-07-13 11:31

from django.db import migrations, models

import chores.models


class Migration(migrations.Migration):
dependencies = [
("chores", "0005_chorenotification"),
]

operations = [
migrations.AlterField(
model_name="chore",
name="configuration",
field=models.JSONField(validators=[chores.models.validate_json]),
),
migrations.AlterField(
model_name="historicalchore",
name="configuration",
field=models.JSONField(validators=[chores.models.validate_json]),
),
]
50 changes: 48 additions & 2 deletions chores/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json

import jsonfield
from django.core import exceptions
from django.db import models
from simple_history.models import HistoricalRecords
Expand All @@ -20,7 +19,7 @@ class Chore(models.Model):
name = models.CharField(max_length=40, unique=True)
description = models.CharField(max_length=200)
class_type = models.CharField(max_length=40)
configuration = jsonfield.JSONField(validators=[validate_json])
configuration = models.JSONField(validators=[validate_json])
wiki_url = models.URLField(blank=True, null=True)
creator = models.ForeignKey(
User,
Expand Down Expand Up @@ -49,8 +48,55 @@ class ChoreVolunteer(models.Model):
on_delete=models.CASCADE,
related_name="chore",
)

# Represents when the chore is scheduled to begin
timestamp = models.IntegerField(null=False)

created_at = models.DateTimeField(auto_now_add=True)

history = HistoricalRecords()

@property
def first_name(self):
return self.user.first_name

@property
def full_name(self):
return f"{self.user.first_name} {self.user.last_name}"


class ChoreNotification(models.Model):
event_key = models.CharField(max_length=128, unique=True)
chore = models.ForeignKey(
Chore,
null=False,
on_delete=models.CASCADE,
related_name="notifications",
)
recipient_user = models.ForeignKey(
User,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="chore_notifications",
)

recipient_other = models.EmailField(null=True, blank=True)

created_at = models.DateTimeField(auto_now_add=True)

def clean(self):
from django.core.exceptions import ValidationError

if not self.recipient_user and not self.recipient_other:
raise ValidationError(
"Must have either a recipient_user or a recipient_other."
)
if self.recipient_user and self.recipient_other:
raise ValidationError(
"Cannot have both recipient_user and recipient_other."
)

def __str__(self):
recipient = self.recipient_user if self.recipient_user else self.recipient_other
return f"Notification to {recipient} for {self.chore} at {self.created_at}"
49 changes: 31 additions & 18 deletions chores/templates/chores.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@

{% block content %}

<p>
Below is the list of chores for the next 3 months.
</p>
<p>
Please sign up for the activities you can help with. If a chore is getting close and nobody has signed up yet, the system will send out reminders.
</p>
<p>
For the engineers - this data is also accessible as a JSON from <code><a href="{% url "chores_api"%}">{% url "chores_api"%}</a></code>.
</p>

<div class="row">
<div class="col-xl-12 col-lg-12 col-sm-12">
<!-- Chores -->
<div class="pt-0 pb-1">
{% if event_groups %}
<p>
Below is the list of chores for the next 3 months.
</p>
<p>
Please sign up for the activities you can help with. If a chore is getting close and nobody has signed up yet, the system will send out reminders.
</p>
<p>
For the engineers - this data is also accessible as a JSON from <code><a href="{% url "chores_api"%}">{% url "chores_api"%}</a></code>.
</p>
<div class="card mt-2 mb-2">
<ul class="list-group list-group-flush pt-0 pb-0">
{% for group in event_groups %}
Expand Down Expand Up @@ -47,15 +46,20 @@
<li style="list-style-type: none; padding-bottom:0 !important">
<div>
{% if volunteer == 'offer_volunteering' %}
<a href="{% url 'signup_chore' chore_id=event.chore.chore_id ts=event.when.timestamp %}?redirect_to=chores" class="btn btn-outline-secondary btn-sm">Add me as volunteer</a>
<a href="{% url 'signup_chore' chore_id=event.chore.chore_id ts=event.when.timestamp %}?redirect_to=chores"
class="btn btn-outline-secondary btn-sm"
data-test-hook="chores-volunteer-button">
Add me as volunteer
</a>
{% elif volunteer is None %}
<i>Volunteer needed</i>
{% elif volunteer.id %}
Volunteer:
<a href="{{volunteer.path}}">
{{ volunteer.first_name }}
</a>

<div data-test-hook="chores-volunteer">
Volunteer:
<a href="{{volunteer.path}}">
{{ volunteer.first_name }}
</a>
</div>
{% endif %}
</div>
</li>
Expand All @@ -65,7 +69,10 @@
{% if event.user_volunteered %}
<li style="list-style-type: none; margin-top: 0.3em; padding-bottom:0 !important">
<div>
<a href="{% url 'remove_signup_chore' chore_id=event.chore.chore_id ts=event.when.timestamp %}?redirect_to=chores" class="btn btn-outline-secondary btn-sm">Remove me as volunteer</a>
<a href="{% url 'remove_signup_chore' chore_id=event.chore.chore_id ts=event.when.timestamp %}?redirect_to=chores"
class="btn btn-outline-secondary btn-sm"
data-test-hook="chores-remove-volunteer">
Remove me as volunteer</a>
</div>
</li>
{% endif %}
Expand All @@ -78,7 +85,13 @@
</ul>
</div>
{% else %}
No upcoming tasks for the next 2 weeks, view all upcoming chores <a href="{% url 'chores' %}" class="chores-link">here</a>.
<div data-test-hook="empty-chores" class="mt-2">
<p>No upcoming tasks for the next 3 months.</p>
<p>
As a busy space with plenty to do that seems unlikely.<br/>
Something may be broken, let <a href="mailto:[email protected]">[email protected]</a>
</p>
</div>
{% endif %}
</div>
</div>
Expand Down
1 change: 0 additions & 1 deletion chores/tests.py

This file was deleted.

Empty file added chores/tests/__init__.py
Empty file.
15 changes: 15 additions & 0 deletions chores/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import factory
from django.contrib.auth import get_user_model

User = get_user_model()


class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User

email = factory.Faker("email")
first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name")
telegram_user_id = factory.Faker("uuid4")
password = factory.PostGenerationMethodCall("set_password", "password123")
Loading