Skip to content

Commit

Permalink
Merge pull request #77 from wildjames/dev
Browse files Browse the repository at this point in the history
Implement One-Shot tasks. Some Readme update
  • Loading branch information
wildjames authored Nov 6, 2023
2 parents 1ec9c6c + 6c9ce81 commit 77dd676
Show file tree
Hide file tree
Showing 20 changed files with 781 additions and 110 deletions.
54 changes: 41 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,58 @@
# ToDoQu

Life is full of small tasks that don't need to be done very often, but do need to be done. The natural solution is to batch these jobs together, and knock them out in chunks - most people would call this "chores", or "cleaning". However, sometimes it's easier to do small parts of these jobs every now and then, sacrificing the time equivalent of pocket change daily, rather than putting aside a lump sum on payday. No-one wants to sacrifice all of a Saturday cleaning a house, but a few minutes a day can eliminate the need to do it at all.
Life is full of small tasks that don't need to be done very often, but do need to be done. The natural solution is to batch these jobs together, and knock them out in chunks - most people would call this "chores", or "cleaning". However, sometimes it's easier to do small parts of these jobs every now and then, sacrificing the time equivalent of pocket change daily, rather than putting aside a lump sum on payday. No-one wants to sacrifice all of a Saturday cleaning a house, but a few minutes a day can eliminate the feeling of spending time cleaning.

ToDoQu is a flexible queue of what needs to be done. You can atomize chores, and be reminded to do them at your own pace when you get the time. Jobs are marked as done, and after some time become "**stale**". Stale tasks need to be completed, and become **overdue** after some window. Each job has its own schedule, and a scoreboard tracks who's done how much.

Atomizing and distributing chores across both people and time makes it easier to maintain a high level of order in a home.

To see a live version of this app, please take a look at the [instance I host on my home server](https://todoqu.wildjames.com/).

# Live deployment

## An example
There is a live version of this site, deployed automatically from the `main` branch. A Github workflow automatically builds [this docker image](https://hub.docker.com/repository/docker/wizenedchimp/todoqueue/general), which is hosted on my [home server](https://todoqu.wildjames.com/). Feel free to sign up and try it out.

Personally, I believe the task of properly cleaning a house to be impossible. Any sane person gets bored, and inevitably half-asses the last hour or two. Even if they didn't, not everything always needs to be done. To illustrate, consider a single room.

Cleaning the living room is actually several tasks. We need to clear debris from the coffeetable and put it away, and we need to fold the throw blanket and plump the pillows. However, this is only surface level - the skirting boards need to be wiped down every now and again, the room needs to be vacuumed, and the sofa needs to be scrubbed of cat hair. Notice that some of these things need to be done only infrequently. Most people would call them "Spring cleaning", and realistically not bother.
# Running your own server

Using ToDoQu, we could set the vacuuming to be done at least every 5 days, and at most every 9. You would get a nudge 5 days after marking the task as done, suggesting it needs doing again. Leaving it more than 9 days will start it pulsing, and raise it to the top of the screen. Something like clearing the coffeetable can be set to be done daily, but allow a week or more since it may not always need doing, and wiping the skirting board can be set to need doing after several months, with a month or two to get done.
ToDoQu is made up of four components. A Django backend, a React frontend, a SQL database for persistent storage, and a Redis cache. The redis cache currently only tracks excessive requests from users, so this can optionally be ignored and local memory used instead.

ToDoQu works better the more you can break down tasks. However - I recommend not splitting a chore across two tasks! For example, "Do the laundry" is good, "Put the laundry in" and "Take the laundry out" is not.
## Docker Compose

The simplest way to run an instance of ToDoQu is to use the provided docker compose stack. This starts the fontend and backend in the `web` container, along with a Redis cache and a MySQL database. From the top directory of this repo, run
```bash
docker compose up --build
```
This will run an instance of the site that should become available at `http://localhost`

## Brownie Points

ToDoQu is able to track the core contributions from its users. Completing tasks awards brownie points based on three factors:
- The time it took
- How gross was it to do
- Some random jitter
## Separating concerns

The advantage of running the codes outside of docker is that changes to the code will be reflected as they are made.

To configure Django you will need to set some environment variables. This is set up to load a `.env` file, or get variables directly from the environment.
To see what variables need to be set, along with reasonable defaults, please examine the given [.env](./todoqueue_backend/.env) file, or the `docker-compose.yml` configuration.

Note that you will need a MySQL database already running. The redis cache is optional, and can comfortably be omitted for local development.

### Django

First, install the prerequisites. From the backend directory, [./todoqueue_backend](./todoqueue_backend/), run
```bash
pip install -r requirements.txt
```
It's best to do this in a fresh virtual environment (e.g. anaconda).

To launch the backend itself, configure the [.env](./todoqueue_backend/.env) file and run the server, instructing it to use the development mode values:
```bash
DEV=true python3 manage.py runserver
```

When running in production mode (i.e. without `DEV=true`), variables will be simply fetched from the system environment.

### React frontend

Simply enter the frontend directory, and run the frontend server
```bash
npm start
```

The random jitter is just there to keep the numbers more interesting. Note that multiple people can be credited with doing a task, and tasks can be dismissed without crediting anyone at all.
43 changes: 28 additions & 15 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,47 @@ services:
ports:
- "80:80"
environment:
DJANGO_DB_NAME: todoqueue
DJANGO_DB_USER: todo_user
DJANGO_DB_PASSWORD: todo_password
DJANGO_DB_HOST: db
DJANGO_DB_PORT: "3306"

EMAIL_HOST: smtp-mail.outlook.com
EMAIL_PORT: 587
EMAIL_USE_TLS: "1"
EMAIL_HOST_USER:
DEFAULT_FROM_EMAIL:
EMAIL_HOST_PASSWORD:
EMAIL_HOST_USER: [email protected]
DEFAULT_FROM_EMAIL: [email protected]
EMAIL_HOST_PASSWORD: MyPassword!

DJANGO_DEBUG: true
DJANGO_LOGGING_LEVEL: debug

DJANGO_HOST_PORT: "8000"
DJANGO_DB_NAME:
DJANGO_DB_USER:
DJANGO_DB_PASSWORD:
DJANGO_DB_HOST:
DJANGO_DB_PORT:

DJANGO_CACHE_BACKEND: "redis"
DJANGO_CACHE_LOCATION: "redis://cache:8379/1"
DJANGO_CACHE_LOCATION: "redis://cache:6379/1"

DJANGO_SUPERUSER_EMAIL:
DJANGO_SUPERUSER_USERNAME:
DJANGO_SUPERUSER_PASSWORD:
DJANGO_SUPERUSER_EMAIL: [email protected]
DJANGO_SUPERUSER_USERNAME: James
DJANGO_SUPERUSER_PASSWORD: MyPassword!
depends_on:
- db
- cache

cache:
image: "redis:alpine"
ports:
- "8379:6379"


db:
image: mysql:8.2
volumes:
- mysql_data:/var/lib/mysql
environment:
MYSQL_DATABASE: todoqueue
MYSQL_USER: todo_user
MYSQL_PASSWORD: todo_password
MYSQL_ROOT_PASSWORD: root_password


volumes:
mysql_data:
23 changes: 23 additions & 0 deletions todoqueue_backend/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
EMAIL_HOST=smtp-mail.outlook.com
EMAIL_PORT=587
EMAIL_USE_TLS=1
EMAIL_HOST_USER=[email protected]
DEFAULT_FROM_EMAIL=[email protected]
EMAIL_HOST_PASSWORD=MyPassword!

DJANGO_DEBUG=true
DJANGO_LOGGING_LEVEL=debug
DJANGO_HOST_PORT=8000

DJANGO_DB_NAME=todoqueue
DJANGO_DB_USER=root
DJANGO_DB_PASSWORD=password
DJANGO_DB_HOST=localhost
DJANGO_DB_PORT=3306

DJANGO_CACHE_BACKEND=redis
DJANGO_CACHE_LOCATION=redis://127.0.0.1:6379/1

DJANGO_SUPERUSER_EMAIL=[email protected]
DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_PASSWORD=MyPassword!
2 changes: 2 additions & 0 deletions todoqueue_backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ python-decouple==3.8
distlib==0.3.5
Django==4.2.5
django-cors-headers==4.2.0
django-environ==0.11.2
django-rest-framework==0.1.0
django-redis==5.4.0
djangorestframework==3.14.0
Expand All @@ -13,6 +14,7 @@ gunicorn==21.2.0
mysqlclient==2.2.0
packaging==23.1
platformdirs==2.5.2
python-dotenv==1.0.0
pytz==2023.3.post1
redis==5.0.1
sqlparse==0.4.4
Expand Down
3 changes: 2 additions & 1 deletion todoqueue_backend/run_server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ echo "Starting Nginx..."
nginx &

# Default to a host port of 8000, and if the environment variable is set then use that
if [[ -z "$DJANGO_HOST_PORT" ]]; then
if [ -z "$DJANGO_HOST_PORT" ]; then
echo "Using default Django port of 8000"
export DJANGO_HOST_PORT=8000
fi

Expand Down
30 changes: 30 additions & 0 deletions todoqueue_backend/tasks/migrations/0006_oneshottask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 4.2.5 on 2023-11-06 14:28

from django.db import migrations, models
import django.db.models.deletion
import tasks.models
import uuid


class Migration(migrations.Migration):

dependencies = [
('tasks', '0005_invitation'),
]

operations = [
migrations.CreateModel(
name='OneShotTask',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('task_name', models.CharField(max_length=255, validators=[tasks.models.validate_profanity])),
('description', models.TextField(default='', validators=[tasks.models.validate_profanity])),
('due_date', models.DateField()),
('due_before', models.BooleanField(default=False)),
('time_to_complete', models.DurationField(default='0:0')),
('frozen', models.BooleanField(default=False)),
('has_completed', models.BooleanField(default=False)),
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oneshot_tasks', to='tasks.household')),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-11-06 14:33

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tasks', '0006_oneshottask'),
]

operations = [
migrations.AlterField(
model_name='oneshottask',
name='due_date',
field=models.DateTimeField(),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 4.2.5 on 2023-11-06 20:50

import datetime
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tasks', '0007_alter_oneshottask_due_date'),
]

operations = [
migrations.AddField(
model_name='oneshottask',
name='last_completed',
field=models.DateTimeField(auto_now_add=True, default=datetime.datetime(2023, 11, 6, 20, 50, 34, 981370, tzinfo=datetime.timezone.utc)),
preserve_default=False,
),
]
90 changes: 87 additions & 3 deletions todoqueue_backend/tasks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,65 @@ def __str__(self):
return self.task_name


class OneShotTask(models.Model):
"""One-shot tasks only appear once, then when they are completed they never stop being fresh again"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

task_name = models.CharField(max_length=255, validators=[validate_profanity])
description = models.TextField(default="", validators=[validate_profanity])
due_date = models.DateTimeField()
due_before = models.BooleanField(default=False)
time_to_complete = models.DurationField(default="0:0")
household = models.ForeignKey(
Household, on_delete=models.CASCADE, related_name="oneshot_tasks"
)
frozen = models.BooleanField(default=False)
last_completed = models.DateTimeField(auto_now_add=True)
has_completed = models.BooleanField(default=False)

@property
def staleness(self):
if self.frozen or self.has_completed:
return 0

now = timezone.now()
remaining = None

if self.due_before:
deadline = self.due_date - self.time_to_complete

if now < deadline:
return 0
remaining = now - deadline

else:
if now < self.due_date:
return 0
remaining = now - self.due_date

logger.debug(f"Remaining: {remaining}")
logger.debug(f"")

# Normalize staleness to a value between 0 and 1
staleness = min(remaining / self.time_to_complete, 1)
return staleness

@property
def mean_completion_time(self):
work_logs = WorkLog.objects.filter(object_id=self.id)
if work_logs.count() == 0:
return timezone.timedelta(seconds=0)

total_completion_time = sum(
[work_log.completion_time for work_log in work_logs], timezone.timedelta()
)
mean_completion_time = total_completion_time / work_logs.count()
return mean_completion_time.total_seconds()

def __str__(self):
return self.task_name


class DummyTask(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
household = models.ForeignKey(
Expand Down Expand Up @@ -280,6 +339,11 @@ def save(self, *args, **kwargs):
try:
# Tasks MUST have a last_completed field
self.content_object.last_completed = timezone.now()

# Check if the task is a OneShotTask and set has_completed to True
if isinstance(self.content_object, OneShotTask):
self.content_object.has_completed = True

self.content_object.save()
except AttributeError:
logger.error(
Expand Down Expand Up @@ -313,7 +377,7 @@ def save(self, *args, **kwargs):
def get_task_by_id(task_id):
"""Returns both the Task object, and the ContentType object for the given task ID, as a tuple."""
# Add other task models to this list as needed
task_models = [DummyTask, FlexibleTask, ScheduledTask]
task_models = [DummyTask, FlexibleTask, ScheduledTask, OneShotTask]

for model in task_models:
try:
Expand Down Expand Up @@ -396,6 +460,24 @@ def get_mean_completion_time(self, obj):
return obj.mean_completion_time


class OneShotTaskSerializer(serializers.ModelSerializer):
staleness = serializers.SerializerMethodField()
mean_completion_time = serializers.SerializerMethodField()
description = serializers.CharField(
required=False, allow_blank=True, validators=[validate_profanity]
)

class Meta:
model = OneShotTask
fields = "__all__"

def get_staleness(self, obj):
return obj.staleness

def get_mean_completion_time(self, obj):
return obj.mean_completion_time


class DummyTaskSerializer(serializers.ModelSerializer):
staleness = serializers.SerializerMethodField()
mean_completion_time = serializers.SerializerMethodField()
Expand All @@ -412,13 +494,15 @@ def get_mean_completion_time(self, obj):


def get_serializer_for_task(
task_instance: FlexibleTask | ScheduledTask | DummyTask,
) -> FlexibleTaskSerializer | ScheduledTaskSerializer | DummyTaskSerializer:
task_instance: FlexibleTask | ScheduledTask | OneShotTask | DummyTask,
) -> FlexibleTaskSerializer | ScheduledTaskSerializer | OneShotTaskSerializer | DummyTaskSerializer:
# Return the appropriate serializer based on the type of task_instance
if isinstance(task_instance, FlexibleTask):
return FlexibleTaskSerializer
if isinstance(task_instance, ScheduledTask):
return ScheduledTaskSerializer
if isinstance(task_instance, OneShotTask):
return OneShotTaskSerializer
if isinstance(DummyTask):
return DummyTaskSerializer

Expand Down
Loading

0 comments on commit 77dd676

Please sign in to comment.