import os import uuid import magic from django import forms from django.db import models from django.utils import timezone from django.utils.text import normalize_newlines from django.core.exceptions import ValidationError from django.template.loader import render_to_string class Thought(models.Model): text = models.CharField(max_length=140, blank=True) extended_text = models.TextField(blank=True) uuid = models.UUIDField(default=uuid.uuid4, editable=False) posted = models.DateTimeField(default=timezone.now) timezone_offset = models.IntegerField() # The number of minutes behind UTC we were when this was posted media = models.FileField(upload_to="", blank=True) # A single image, video, or sound clip per post media_alt = models.TextField(blank=True) # An optional transcription of the Thought's media html_content = models.TextField(blank=True) # The rendered text for this thought last_updated = models.DateTimeField(default=timezone.now) def save(self): self.update_html_content(commit=False) self.last_updated = timezone.now() super().save() def get_media_type(self): if not self.media: return "" else: return os.path.splitext(self.media.path)[1][1:] def get_timezone(self): return timezone.get_fixed_timezone(-self.timezone_offset) def get_offset_hours(self): offset_hours = -self.timezone_offset / 60 # Convert 4.0 to 4 if offset_hours == int(offset_hours): offset_hours = int(offset_hours) if offset_hours > 0: offset_hours = "+" + str(offset_hours) return str(offset_hours) def get_absolute_url(self): return f"/?show={str(self.uuid)}" def get_html_content(self, authenticated=False): return render_to_string("thoughts/thought.html", {"thought": self, "authenticated": authenticated}) def get_html_content_authenticated(self): return self.get_html_content(authenticated=True) def update_html_content(self, commit=True): self.html_content = self.get_html_content() if commit: self.save() @classmethod def find(cls, identifier): try: return cls.objects.get(uuid=identifier) except cls.DoesNotExist: try: return cls.objects.get(id=identifier) except cls.DoesNotExist: return None class Meta: indexes = [ models.Index(fields=["-posted"]), models.Index(fields=["-last_updated"]) ] # Honestly I'm so sick of this problem that writing out a comment here to explain why it is necessary is beyond me. # I'm calling this CharField and not MySpecialLineNormalizingCharField # because I think this should be the default behavior and I don't understand why it's not. class CharField(forms.CharField): def to_python(self, value): # I am assuming that value is always a string and normalize_newlines isn't going to error on any string input return super().to_python(normalize_newlines(value)) # A dict mapping allowed mime types to file extensions ALLOWED_MEDIA_TYPES = { "image/png": "png", "image/jpeg": "jpeg", "audio/x-m4a": "m4a", "audio/mpegaudio/mpeg": "mp3", "audio/mpeg": "mp3", "audio/x-hx-aac-adts": "aac", "video/mp4": "mp4", "video/quicktime": "mov", } class ThoughtForm(forms.ModelForm): text = CharField( strip=False, widget=forms.Textarea(attrs={"class": "thought", "rows": 1, "placeholder": "What are you thinking?"}), required=False, max_length=140 ) extended_text = CharField( strip=False, widget=forms.Textarea(attrs={"class": "thought", "rows": 1, "placeholder": "Anything else?"}), required=False ) media_alt = CharField( widget=forms.Textarea(attrs={"placeholder": "Media alternate text, please", "rows": 1}), required=False ) def clean_media(self): f = self.cleaned_data["media"] if not f: return f # 16 MB file size limit. if f.size > 2**24: raise ValidationError("Content too long", code="max_size") chunk = next(f.chunks(chunk_size=2048)) media_type = magic.from_buffer(chunk, mime="True") if media_type not in ALLOWED_MEDIA_TYPES: raise ValidationError("Invalid media type", code="invalid") return self.cleaned_data["media"] class Meta: model = Thought exclude = ["posted", "last_updated"] widgets = { "timezone_offset": forms.NumberInput, } error_messages = { "text": { "max_length": "Text must be at most 140 characters." }, }