import base64 import binascii import os import uuid import re from django.db.models import Q import bleach from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse from django.utils import timezone from main import util, validators def _generate_key(): """Return 32-char random string.""" return binascii.b2a_hex(os.urandom(16)).decode("utf-8") # custom queryset and manager for Post model to handle tag operations class PostQuerySet(models.QuerySet): def with_tag(self, tag): return self.filter( Q(tags__regex=rf'(^|,){re.escape(tag)}(,|$)') ) def all_unique_tags(self): tags = set() for post in self.exclude(tags__isnull=True).exclude(tags=""): for tag in post.tag_list: tags.add(tag) return sorted(tags) class PostManager(models.Manager): def get_queryset(self): return PostQuerySet(self.model, using=self._db) def with_tag(self, tag): return self.get_queryset().with_tag(tag) def all_unique_tags(self): return self.get_queryset().all_unique_tags() class User(AbstractUser): username = models.CharField( max_length=150, unique=True, help_text="This is your subdomain. Lowercase alphanumeric.", validators=[ validators.AlphanumericHyphenValidator(), validators.HyphenOnlyValidator(), ], error_messages={"unique": "A user with that username already exists."}, ) email = models.EmailField( blank=True, null=True, help_text="Optional, but also the only way to recover password if forgotten.", ) api_key = models.CharField(max_length=32, default=_generate_key, unique=True) about = models.TextField(blank=True, null=True) blog_title = models.CharField(max_length=500, blank=True, null=True) posts_page_title = models.CharField(max_length=500, blank=True, null=True) blog_byline = models.TextField( blank=True, null=True, help_text="Supports markdown", ) blog_index_content = models.TextField( blank=True, null=True, help_text="Supports markdown", ) subscribe_note = models.CharField( max_length=350, blank=True, null=True, default="Subscribe via [RSS](/rss/) / [Atom](/atom/) / [via Email](/newsletter/).", help_text="Default: Subscribe via [RSS](/rss/) / [Atom](/atom/) / [via Email](/newsletter/).", ) footer_note = models.TextField( blank=True, null=True, default="published with [BōcPress](https://bocpress.co.uk/).", help_text="Supports markdown", ) theme_zialucia = models.BooleanField( default=False, verbose_name="Theme Zia Lucia", help_text="Enable/disable Zia Lucia theme with larger default font size.", ) theme_sansserif = models.BooleanField( default=False, verbose_name="Theme Sans-serif", help_text="Use sans-serif font in blog content.", ) redirect_domain = models.CharField( max_length=150, blank=True, null=True, help_text="Retiring your BōcPress blog? We can redirect to your new domain.", validators=[validators.validate_domain_name], ) custom_domain = models.CharField( max_length=150, blank=True, null=True, help_text="To setup: Add an A record in your domain's DNS with IP 78.47.67.77", validators=[validators.validate_domain_name], ) comments_on = models.BooleanField( default=False, help_text="Enable/disable comments for your blog.", verbose_name="Comments", ) notifications_on = models.BooleanField( default=True, help_text="Allow/disallow people subscribing for email newsletter for new posts.", verbose_name="Newsletter", ) mail_export_on = models.BooleanField( default=False, help_text="Enable/disable auto emailing of account exports every month.", verbose_name="Mail export", ) post_backups_on = models.BooleanField( default=False, help_text="Enable/disable automatic post backups.", verbose_name="Post Backups On", ) show_posts_on_homepage = models.BooleanField( default=True, help_text="Show/hide posts on the homepage.", verbose_name="Show Posts On Homepage", ) show_posts_in_nav = models.BooleanField( default=False, help_text="Show/hide posts in the navigation bar.", verbose_name="Show Posts In Nav", ) show_tags_in_post_list = models.BooleanField( default=True, help_text="Show/hide tags in the post list.", verbose_name="Show Tags In Post List", ) noindex_on = models.BooleanField( default=False, help_text="Add a noindex meta tag so your blog is not indexed by search engines.", verbose_name="noindex: Prevent Search Engine Indexing", ) reading_time_on = models.BooleanField( default=False, help_text="Add an estimated reading time - in minutes - to the top of the post body.", verbose_name="Show Reading Time on Posts", ) robots_txt = models.TextField( blank=True, help_text="Custom robots.txt content for your blog. Leave blank for default.", verbose_name="robots.txt", null=True, default="User-agent: *\nDisallow:\nAllow: /", ) export_unsubscribe_key = models.UUIDField(default=uuid.uuid4, unique=True) number_of_posts_feed = models.IntegerField( blank=False, help_text="Number of posts to show in RSS/Atom feed. Default is 10.", verbose_name="Number of Posts in Feed", null=False, default=10, ) # webring related webring_name = models.CharField(max_length=200, blank=True, null=True) webring_url = models.URLField( blank=True, null=True, verbose_name="Webring info URL", help_text="Informational URL.", ) webring_prev_url = models.URLField( blank=True, null=True, verbose_name="Webring previous URL", help_text="URL for your webring's previous website.", ) webring_next_url = models.URLField( blank=True, null=True, verbose_name="Webring next URL", help_text="URL for your webring's next website.", ) markdown_auto_format_on = models.BooleanField( default=False, help_text="Enable/disable automatic markdown formatting.", verbose_name="Auto Markdown formatting", ) # billing stripe_customer_id = models.CharField(max_length=100, blank=True, null=True) stripe_subscription_id = models.CharField(max_length=100, blank=True, null=True) monero_address = models.CharField(max_length=95, blank=True, null=True) is_premium = models.BooleanField(default=False) is_grandfathered = models.BooleanField(default=False) # moderation is_approved = models.BooleanField(default=False) class Meta: ordering = ["-id"] @property def blog_absolute_url(self): protocol = f"{util.get_protocol()}" return f"{protocol}//{self.username}.{settings.CANONICAL_HOST}" @property def blog_url(self): url = f"{util.get_protocol()}" if self.custom_domain: return url + f"//{self.custom_domain}" else: return url + f"//{self.username}.{settings.CANONICAL_HOST}" @property def blog_byline_as_text(self): linker = bleach.linkifier.Linker(callbacks=[lambda attrs, new: None]) html_text = util.md_to_html(self.blog_byline, strip_tags=True) return linker.linkify(html_text) @property def blog_byline_as_html(self): return util.md_to_html(self.blog_byline) @property def blog_index_content_as_text(self): linker = bleach.linkifier.Linker(callbacks=[lambda attrs, new: None]) html_text = util.md_to_html(self.blog_index_content, strip_tags=True) return linker.linkify(html_text) @property def blog_index_content_as_html(self): return util.md_to_html(self.blog_index_content) @property def about_as_html(self): return util.md_to_html(self.about, strip_tags=True) @property def subscribe_note_as_html(self): return util.md_to_html(self.subscribe_note) @property def footer_note_as_html(self): return util.md_to_html(self.footer_note) @property def post_count(self): return Post.objects.filter(owner=self).count() @property def class_status(self): if self.is_premium or self.is_grandfathered: return "💠" return "∅" def get_export_unsubscribe_url(self): domain = self.custom_domain or f"{self.username}.{settings.CANONICAL_HOST}" path = reverse("export_unsubscribe_key", args={self.export_unsubscribe_key}) return f"//{domain}{path}" def reset_api_key(self): self.api_key = _generate_key() self.save() def __str__(self): return self.username class Post(models.Model): title = models.CharField(max_length=300) slug = models.CharField(max_length=300) body = models.TextField(blank=True, null=True) owner = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) published_at = models.DateField( default=timezone.now, blank=True, null=True, help_text="Leave blank to keep as draft/unpublished. Use a future date for auto-posting.", ) broadcasted_at = models.DateTimeField(blank=True, null=True, default=None) tags = models.CharField(max_length=300, blank=True, null=True, default=None, help_text="Enter comma-separated tags (e.g., django, python, blog).") class Meta: ordering = ["-published_at", "-created_at"] unique_together = [["slug", "owner"]] objects = PostManager() @property def body_as_html(self): return util.md_to_html(self.body) @property def body_as_text(self): as_html = util.md_to_html(self.body) return bleach.clean(as_html, strip=True, tags=[]) @property def tag_list(self): if self.tags: return [t.strip() for t in self.tags.split(",") if t.strip()] return [] @property def is_draft(self): return not self.published_at @property def is_published(self): # draft case if not self.published_at: return False # future publishing date case if self.published_at > timezone.now().date(): # noqa: SIM103 return False return True def get_absolute_url(self): path = reverse("post_detail", kwargs={"slug": self.slug}) return f"//{self.owner.username}.{settings.CANONICAL_HOST}{path}" def get_proper_url(self): """Returns custom domain URL if custom_domain exists, else subdomain URL.""" if self.owner.custom_domain: path = reverse("post_detail", kwargs={"slug": self.slug}) return f"//{self.owner.custom_domain}{path}" else: return self.get_absolute_url() def save(self, *args, **kwargs): if self.tags: cleaned_tags = [t.strip() for t in self.tags.split(",") if t.strip()] self.tags = ",".join(cleaned_tags) super().save(*args, **kwargs) def __str__(self): return self.title class Image(models.Model): owner = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=300) # original filename slug = models.CharField(max_length=300, unique=True) data = models.BinaryField() extension = models.CharField(max_length=10) uploaded_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-uploaded_at"] @property def filename(self): return self.slug + "." + self.extension @property def data_as_base64(self): return base64.b64encode(self.data).decode("utf-8") @property def data_size(self): """Get image size in MB.""" return round(len(self.data) / (1024 * 1024), 2) @property def raw_url_absolute(self): path = reverse( "image_raw", kwargs={"slug": self.slug, "extension": self.extension} ) return f"//{settings.CANONICAL_HOST}{path}" def get_absolute_url(self): path = reverse("image_detail", kwargs={"slug": self.slug}) return f"//{settings.CANONICAL_HOST}{path}" def __str__(self): return self.name class Page(models.Model): title = models.CharField(max_length=300) slug = models.CharField( max_length=300, validators=[validators.AlphanumericHyphenValidator()], help_text="Lowercase letters, numbers, and - (hyphen) allowed.", ) body = models.TextField(blank=True, null=True) owner = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) is_hidden = models.BooleanField( default=False, help_text="If checked, page link will not appear on the blog header.", ) class Meta: ordering = ["slug"] unique_together = [["slug", "owner"]] @property def body_as_html(self): return util.md_to_html(self.body) def get_absolute_url(self): path = reverse("page_detail", kwargs={"slug": self.slug}) return f"//{self.owner.username}.{settings.CANONICAL_HOST}{path}" def __str__(self): return self.title class AnalyticPage(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) path = models.CharField(max_length=300) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] def __str__(self): return self.created_at.strftime("%c") + ": " + self.user.username class AnalyticPost(models.Model): post = models.ForeignKey(Post, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] def __str__(self): return self.created_at.strftime("%c") + ": " + self.post.title class Comment(models.Model): post = models.ForeignKey(Post, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) body = models.TextField() name = models.CharField(max_length=150, default="Anonymous", null=True, blank=True) email = models.EmailField(null=True, blank=True) is_approved = models.BooleanField(default=False) is_author = models.BooleanField( default=False, help_text="True if logged in author has posted comment." ) class Meta: ordering = ["created_at"] @property def body_as_html(self): return util.md_to_html(self.body) def get_absolute_url(self): path = reverse("post_detail", kwargs={"slug": self.post.slug}) return f"//{self.post.owner.username}.{settings.CANONICAL_HOST}{path}#comment-{self.id}" def __str__(self): return self.created_at.strftime("%c") + ": " + self.post.title class Notification(models.Model): blog_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) email = models.EmailField() unsubscribe_key = models.UUIDField(default=uuid.uuid4, unique=True) is_active = models.BooleanField(default=True) class Meta: ordering = ["email"] unique_together = [["email", "blog_user"]] def get_unsubscribe_url(self): domain = ( self.blog_user.custom_domain or f"{self.blog_user.username}.{settings.CANONICAL_HOST}" ) path = reverse("notification_unsubscribe_key", args={self.unsubscribe_key}) return f"//{domain}{path}" def __str__(self): return self.email + " – " + str(self.unsubscribe_key) class NotificationRecord(models.Model): """ NotificationRecord model is to keep track of all notifications for the newsletter feature. """ notification = models.ForeignKey(Notification, on_delete=models.SET_NULL, null=True) post = models.ForeignKey(Post, on_delete=models.SET_NULL, null=True) sent_at = models.DateTimeField(default=timezone.now, null=True) class Meta: ordering = ["-sent_at"] unique_together = [["post", "notification"]] def __str__(self): if not self.sent_at: return str(self.id) if self.notification: return self.sent_at.strftime("%c") + " – " + self.notification.email else: return self.sent_at.strftime("%c") + " – NULL" class ExportRecord(models.Model): """ExportRecord model is to keep track of each export email.""" name = models.CharField(max_length=150) user = models.ForeignKey(User, on_delete=models.CASCADE) sent_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-sent_at"] def __str__(self): return self.name class Snapshot(models.Model): """Snapshot model is used to keep track of all versions of Posts.""" title = models.CharField(max_length=300) body = models.TextField(blank=True, null=True) owner = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-created_at"] def __str__(self): return self.title class Onboard(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) code = models.UUIDField(default=uuid.uuid4, unique=True) problems = models.CharField(max_length=300, null=True, blank=True) quality = models.CharField(max_length=300, null=True, blank=True) seo = models.CharField(max_length=300, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): return f"Prb: {self.problems} / Qlt: {self.quality}" class ReallySimpleLicensing(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="reallysimplelicensing") license = models.TextField(blank=True, null=True, help_text="License text in XML format defined by the Really Simple Licensing standard. Will be available at /license.xml.") show_http = models.BooleanField(default=True, verbose_name="Show HTTP License", help_text="Show/hide license in HTTP header.") show_rss = models.BooleanField(default=True, verbose_name="Show RSS License", help_text="Show/hide license in RSS feed.") show_robotstxt = models.BooleanField(default=True, verbose_name="Show robots.txt License", help_text="Show/hide license in robots.txt.") show_webpage = models.BooleanField(default=True, verbose_name="Show Webpage License", help_text="Show/hide license on webpage header.")