diff --git a/README.md b/README.md index 54dad60..9a9aca1 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,32 @@ LOGGING = { ``` +## Custom models + +It's possible to use your own models with the `DJANGO_TODO_MODELS` setting: + +```python +DJANGO_TODO_MODELS = { + "Task": "my_app.models.MyTask", # defaults to "todo.models.Task" + "TaskList": "my_app.models.MyTaskList", # defaults to "todo.models.TaskList" + "Comment": "my_app.models.MyComment", # defaults to "todo.models.Comment" + "Attachment": "my_app.models.MyAttachment", # defaults to "todo.models.Attachment" +} +``` + +When you define your own model by deriving from a model in django-todo, +import from `todo.default_models` and not from `todo.models` otherwise you will get +an error about having a circular import: + + +```python +from todo.default_models import Task + +class MyTask(Task): + pass +``` + + ## Running Tests django-todo uses pytest exclusively for testing. The best way to run the suite is to clone django-todo into its own directory, install pytest, then: diff --git a/todo/default_models.py b/todo/default_models.py new file mode 100644 index 0000000..ab8b5ae --- /dev/null +++ b/todo/default_models.py @@ -0,0 +1,150 @@ +from __future__ import unicode_literals + +import datetime +import os +import textwrap + +from django.conf import settings +from django.contrib.auth.models import Group +from django.db import models +from django.urls import reverse +from django.utils import timezone + + +def get_attachment_upload_dir(instance, filename): + """Determine upload dir for task attachment files. + """ + + return "/".join(["tasks", "attachments", str(instance.task.id), filename]) + + +class TaskList(models.Model): + name = models.CharField(max_length=60) + slug = models.SlugField(default="") + group = models.ForeignKey(Group, on_delete=models.CASCADE) + + def __str__(self): + return self.name + + class Meta: + ordering = ["name"] + verbose_name_plural = "Task Lists" + + # Prevents (at the database level) creation of two lists with the same slug in the same group + unique_together = ("group", "slug") + + +class Task(models.Model): + title = models.CharField(max_length=140) + task_list = models.ForeignKey(TaskList, on_delete=models.CASCADE, null=True) + created_date = models.DateField(default=timezone.now, blank=True, null=True) + due_date = models.DateField(blank=True, null=True) + completed = models.BooleanField(default=False) + completed_date = models.DateField(blank=True, null=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="todo_created_by", + on_delete=models.CASCADE, + ) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + related_name="todo_assigned_to", + on_delete=models.CASCADE, + ) + note = models.TextField(blank=True, null=True) + priority = models.PositiveIntegerField(blank=True, null=True) + + # Has due date for an instance of this object passed? + def overdue_status(self): + "Returns whether the Tasks's due date has passed or not." + if self.due_date and datetime.date.today() > self.due_date: + return True + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse("todo:task_detail", kwargs={"task_id": self.id}) + + # Auto-set the Task creation / completed date + def save(self, **kwargs): + if self.completed: + self.completed_date = datetime.datetime.now() + super(Task, self).save() + + def merge_into(self, merge_target): + if merge_target.pk == self.pk: + raise ValueError("can't merge a task with self") + + # lock the comments to avoid concurrent additions of comments after the + # update request. these comments would be irremediably lost because of + # the cascade clause + with LockedAtomicTransaction(Comment): + Comment.objects.filter(task=self).update(task=merge_target) + self.delete() + + class Meta: + ordering = ["priority", "created_date"] + + +class Comment(models.Model): + """ + Not using Django's built-in comments because we want to be able to save + a comment and change task details at the same time. Rolling our own since it's easy. + """ + + author = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True + ) + task = models.ForeignKey(Task, on_delete=models.CASCADE) + date = models.DateTimeField(default=datetime.datetime.now) + email_from = models.CharField(max_length=320, blank=True, null=True) + email_message_id = models.CharField(max_length=255, blank=True, null=True) + + body = models.TextField(blank=True) + + class Meta: + # an email should only appear once per task + unique_together = ("task", "email_message_id") + + @property + def author_text(self): + if self.author is not None: + return str(self.author) + + assert self.email_message_id is not None + return str(self.email_from) + + @property + def snippet(self): + body_snippet = textwrap.shorten(self.body, width=35, placeholder="...") + # Define here rather than in __str__ so we can use it in the admin list_display + return "{author} - {snippet}...".format(author=self.author_text, snippet=body_snippet) + + def __str__(self): + return self.snippet + + +class Attachment(models.Model): + """ + Defines a generic file attachment for use in M2M relation with Task. + """ + + task = models.ForeignKey(Task, on_delete=models.CASCADE) + added_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + timestamp = models.DateTimeField(default=datetime.datetime.now) + file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255) + + def filename(self): + return os.path.basename(self.file.name) + + def extension(self): + name, extension = os.path.splitext(self.file.name) + return extension + + def __str__(self): + return f"{self.task.id} - {self.file.name}" diff --git a/todo/models.py b/todo/models.py index c79d3dc..aba0667 100644 --- a/todo/models.py +++ b/todo/models.py @@ -1,22 +1,9 @@ -from __future__ import unicode_literals - -import datetime -import os -import textwrap +import importlib from django.conf import settings -from django.contrib.auth.models import Group -from django.db import DEFAULT_DB_ALIAS, models +from django.db import DEFAULT_DB_ALIAS from django.db.transaction import Atomic, get_connection -from django.urls import reverse -from django.utils import timezone - - -def get_attachment_upload_dir(instance, filename): - """Determine upload dir for task attachment files. - """ - - return "/".join(["tasks", "attachments", str(instance.task.id), filename]) +from todo.default_models import get_attachment_upload_dir # noqa class LockedAtomicTransaction(Atomic): @@ -52,134 +39,14 @@ class LockedAtomicTransaction(Atomic): cursor.close() -class TaskList(models.Model): - name = models.CharField(max_length=60) - slug = models.SlugField(default="") - group = models.ForeignKey(Group, on_delete=models.CASCADE) - - def __str__(self): - return self.name - - class Meta: - ordering = ["name"] - verbose_name_plural = "Task Lists" - - # Prevents (at the database level) creation of two lists with the same slug in the same group - unique_together = ("group", "slug") - - -class Task(models.Model): - title = models.CharField(max_length=140) - task_list = models.ForeignKey(TaskList, on_delete=models.CASCADE, null=True) - created_date = models.DateField(default=timezone.now, blank=True, null=True) - due_date = models.DateField(blank=True, null=True) - completed = models.BooleanField(default=False) - completed_date = models.DateField(blank=True, null=True) - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - null=True, - blank=True, - related_name="todo_created_by", - on_delete=models.CASCADE, +def import_model(model_name): + module_map = getattr(settings, "DJANGO_TODO_MODELS", {}) + module, klass = module_map.get(model_name, "todo.default_models.%s" % model_name).rsplit( + ".", 1 ) - assigned_to = models.ForeignKey( - settings.AUTH_USER_MODEL, - blank=True, - null=True, - related_name="todo_assigned_to", - on_delete=models.CASCADE, - ) - note = models.TextField(blank=True, null=True) - priority = models.PositiveIntegerField(blank=True, null=True) + return getattr(importlib.import_module(module), klass) - # Has due date for an instance of this object passed? - def overdue_status(self): - "Returns whether the Tasks's due date has passed or not." - if self.due_date and datetime.date.today() > self.due_date: - return True - - def __str__(self): - return self.title - - def get_absolute_url(self): - return reverse("todo:task_detail", kwargs={"task_id": self.id}) - - # Auto-set the Task creation / completed date - def save(self, **kwargs): - # If Task is being marked complete, set the completed_date - if self.completed: - self.completed_date = datetime.datetime.now() - super(Task, self).save() - - def merge_into(self, merge_target): - if merge_target.pk == self.pk: - raise ValueError("can't merge a task with self") - - # lock the comments to avoid concurrent additions of comments after the - # update request. these comments would be irremediably lost because of - # the cascade clause - with LockedAtomicTransaction(Comment): - Comment.objects.filter(task=self).update(task=merge_target) - self.delete() - - class Meta: - ordering = ["priority", "created_date"] - - -class Comment(models.Model): - """ - Not using Django's built-in comments because we want to be able to save - a comment and change task details at the same time. Rolling our own since it's easy. - """ - - author = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True - ) - task = models.ForeignKey(Task, on_delete=models.CASCADE) - date = models.DateTimeField(default=datetime.datetime.now) - email_from = models.CharField(max_length=320, blank=True, null=True) - email_message_id = models.CharField(max_length=255, blank=True, null=True) - - body = models.TextField(blank=True) - - class Meta: - # an email should only appear once per task - unique_together = ("task", "email_message_id") - - @property - def author_text(self): - if self.author is not None: - return str(self.author) - - assert self.email_message_id is not None - return str(self.email_from) - - @property - def snippet(self): - body_snippet = textwrap.shorten(self.body, width=35, placeholder="...") - # Define here rather than in __str__ so we can use it in the admin list_display - return "{author} - {snippet}...".format(author=self.author_text, snippet=body_snippet) - - def __str__(self): - return self.snippet - - -class Attachment(models.Model): - """ - Defines a generic file attachment for use in M2M relation with Task. - """ - - task = models.ForeignKey(Task, on_delete=models.CASCADE) - added_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - timestamp = models.DateTimeField(default=datetime.datetime.now) - file = models.FileField(upload_to=get_attachment_upload_dir, max_length=255) - - def filename(self): - return os.path.basename(self.file.name) - - def extension(self): - name, extension = os.path.splitext(self.file.name) - return extension - - def __str__(self): - return f"{self.task.id} - {self.file.name}" +TaskList = import_model("TaskList") +Task = import_model("Task") +Comment = import_model("Comment") +Attachment = import_model("Attachment")