import re import secrets from django.conf import settings from django.db import models from django.db.models.signals import post_delete from django.dispatch import receiver from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ TAG_RE = re.compile(r'(?= len(fence): fence = None visible_lines.append('') elif match: fence = match.group(1) visible_lines.append('') else: visible_lines.append(line) return INLINE_CODE_RE.sub('', '\n'.join(visible_lines)) def delete_unused_tags(tags=None): """Delete tags that are no longer used by items or tag-based features.""" candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags]) candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude( name__in=Team.objects.values_list('slug', flat=True), ).delete() class Tag(models.Model): name = models.SlugField(max_length=80, unique=True, allow_unicode=True) class Meta: ordering = ['name'] def __str__(self): return f'#{self.name}' class UserInvite(models.Model): email = models.EmailField() token = models.CharField(max_length=80, unique=True, editable=False) invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_user_invites') created_at = models.DateTimeField(auto_now_add=True) accepted_at = models.DateTimeField(blank=True, null=True) class Meta: ordering = ['-created_at'] def save(self, *args, **kwargs): if not self.token: self.token = secrets.token_urlsafe(32) super().save(*args, **kwargs) def __str__(self): return f'Benutzereinladung {self.email}' class Team(models.Model): name = models.CharField(max_length=120) slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams') created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['name'] def __str__(self): return self.name class TeamMembership(models.Model): class Role(models.TextChoices): OWNER = 'owner', 'Owner' ADMIN = 'admin', 'Admin' MEMBER = 'member', 'Mitglied' team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='memberships') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='team_memberships') role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = [('team', 'user')] def __str__(self): return f'{self.user} in {self.team}' class TeamInvite(models.Model): team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='invites') email = models.EmailField(blank=True) token = models.CharField(max_length=80, unique=True, editable=False) invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_team_invites') created_at = models.DateTimeField(auto_now_add=True) accepted_at = models.DateTimeField(blank=True, null=True) class Meta: ordering = ['-created_at'] def save(self, *args, **kwargs): if not self.token: self.token = secrets.token_urlsafe(32) super().save(*args, **kwargs) def __str__(self): return f'Einladung {self.team} {self.email}' class Item(models.Model): class Kind(models.TextChoices): TODO = 'todo', _('Task') NOTE = 'note', _('Note') LINK = 'link', _('Link') JOURNAL = 'journal', _('Journal') class Visibility(models.TextChoices): PRIVATE = 'private', _('Private') TEAM = 'team', _('Team') PUBLIC = 'public', _('Public') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='items') kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE) content = models.TextField() comment = models.TextField(blank=True) visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE) kanban_status = models.CharField(max_length=20, default='inbox', choices=[ ('inbox', _('Inbox')), ('todo', 'Todo'), ('progress', _('In Progress')), ('done', _('Done')), ]) is_done = models.BooleanField(default=False) completed_at = models.DateTimeField(blank=True, null=True) due_at = models.DateTimeField(blank=True, null=True) url = models.URLField(blank=True) tags = models.ManyToManyField(Tag, blank=True, related_name='items') linked_items = models.ManyToManyField('self', blank=True, symmetrical=False, related_name='backlinks') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-created_at'] def __str__(self): return self.content[:80] def get_absolute_url(self): return reverse('item_detail', args=[self.pk]) @property def is_overdue(self): return self.kind == self.Kind.TODO and not self.is_done and self.due_at and self.due_at < timezone.now() @property def is_due_today(self): return self.kind == self.Kind.TODO and not self.is_done and self.due_at and timezone.localdate(self.due_at) == timezone.localdate() def sync_metadata(self): old_tags = list(self.tags.all()) metadata_text = without_markdown_code(self.content) tag_names = {m.group(1).lower() for m in TAG_RE.finditer(metadata_text)} tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names] self.tags.set(tags) delete_unused_tags(old_tags) ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')] self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id)) class Kanban(models.Model): owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='kanbans') name = models.CharField(max_length=120) tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name='kanbans') created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['name'] unique_together = [('owner', 'tag')] def __str__(self): return self.name class Collection(models.Model): class Mode(models.TextChoices): MANUAL = 'manual', _('Manual collection') TAGS = 'tags', _('Tag collection') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections') title = models.CharField(max_length=200) description = models.TextField(blank=True) visibility = models.CharField(max_length=10, choices=Item.Visibility.choices, default=Item.Visibility.PRIVATE) mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.MANUAL) filter_tags = models.ManyToManyField(Tag, blank=True, related_name='tag_collections') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-updated_at'] def __str__(self): return self.title def get_absolute_url(self): return reverse('collection_detail', args=[self.pk]) class CollectionSection(models.Model): collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections') item = models.ForeignKey(Item, on_delete=models.PROTECT, blank=True, null=True, related_name='collection_sections') sub_collection = models.ForeignKey(Collection, on_delete=models.PROTECT, blank=True, null=True, related_name='containing_sections') title = models.CharField(max_length=200, blank=True) position = models.PositiveIntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['position', 'id'] constraints = [ models.CheckConstraint( check=(models.Q(item__isnull=False, sub_collection__isnull=True) | models.Q(item__isnull=True, sub_collection__isnull=False)), name='collection_section_exactly_one_target', ), models.UniqueConstraint(fields=['collection', 'item'], condition=models.Q(item__isnull=False), name='unique_collection_item'), models.UniqueConstraint(fields=['collection', 'sub_collection'], condition=models.Q(sub_collection__isnull=False), name='unique_collection_sub_collection'), models.UniqueConstraint(fields=['collection', 'position'], name='unique_collection_position'), ] def __str__(self): return f'{self.collection}: {self.title or self.item or self.sub_collection}' class UserPreference(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences') language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')]) default_item_kind = models.CharField(max_length=10, choices=Item.Kind.choices, default=Item.Kind.NOTE) show_notes = models.BooleanField(default=True) show_open_todos = models.BooleanField(default=True) show_done_todos = models.BooleanField(default=False) show_links = models.BooleanField(default=True) show_journal = models.BooleanField(default=False) due_first = models.BooleanField(default=True) overview_lines = models.PositiveSmallIntegerField(default=4) def __str__(self): return f'Einstellungen {self.user}' class ApiKey(models.Model): owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys') name = models.CharField(max_length=120, default='API Key') token = models.CharField(max_length=80, unique=True, editable=False) tags = models.ManyToManyField(Tag, blank=True, related_name='api_keys') created_at = models.DateTimeField(auto_now_add=True) is_active = models.BooleanField(default=True) class Meta: ordering = ['-created_at'] def save(self, *args, **kwargs): if not self.token: self.token = secrets.token_urlsafe(32) super().save(*args, **kwargs) def __str__(self): return self.name class Attachment(models.Model): item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='attachments') file = models.FileField(upload_to='attachments/%Y/%m/') uploaded_at = models.DateTimeField(auto_now_add=True) def __str__(self): return self.file.name @receiver(post_delete, sender=Item) def clean_tags_after_item_delete(sender, instance, **kwargs): delete_unused_tags()