diff --git a/src/magplan/migrations/0010_auto_20210624_1041.py b/src/magplan/migrations/0010_auto_20210624_1041.py index a5b74c1..a814858 100644 --- a/src/magplan/migrations/0010_auto_20210624_1041.py +++ b/src/magplan/migrations/0010_auto_20210624_1041.py @@ -5,11 +5,10 @@ import django.db.models.manager from django.db import migrations, models -import magplan.models +from magplan.utils import current_site_id class Migration(migrations.Migration): - dependencies = [ ("sites", "0002_alter_domain_unique"), ("magplan", "0009_auto_20210523_1641"), @@ -40,7 +39,7 @@ class Migration(migrations.Migration): model_name="idea", name="site", field=models.ForeignKey( - default=magplan.models.current_site_id, + default=current_site_id, on_delete=django.db.models.deletion.CASCADE, to="sites.site", ), @@ -49,7 +48,7 @@ class Migration(migrations.Migration): model_name="issue", name="site", field=models.ForeignKey( - default=magplan.models.current_site_id, + default=current_site_id, on_delete=django.db.models.deletion.CASCADE, to="sites.site", ), @@ -58,7 +57,7 @@ class Migration(migrations.Migration): model_name="post", name="site", field=models.ForeignKey( - default=magplan.models.current_site_id, + default=current_site_id, on_delete=django.db.models.deletion.CASCADE, to="sites.site", ), @@ -77,7 +76,7 @@ class Migration(migrations.Migration): model_name="section", name="site", field=models.ForeignKey( - default=magplan.models.current_site_id, + default=current_site_id, on_delete=django.db.models.deletion.CASCADE, to="sites.site", ), @@ -86,7 +85,7 @@ class Migration(migrations.Migration): model_name="stage", name="site", field=models.ForeignKey( - default=magplan.models.current_site_id, + default=current_site_id, on_delete=django.db.models.deletion.CASCADE, to="sites.site", ), diff --git a/src/magplan/models/__init__.py b/src/magplan/models/__init__.py new file mode 100644 index 0000000..4dbbe78 --- /dev/null +++ b/src/magplan/models/__init__.py @@ -0,0 +1,24 @@ +from .user import User, Profile +from .comment import Comment +from .idea import Idea +from .issue import Issue +from .magazine import Magazine +from .post import Post, Attachment +from .section import Section +from .site_preference_model import SitePreferenceModel +from .stage import Stage +from .vote import Vote + + +__all__ = [ + "User", + "Post", + "Comment", + "Idea", + "Issue", + "Magazine", + "Section", + "SitePreferenceModel", + "Stage", + "Vote", +] diff --git a/src/magplan/models/abs.py b/src/magplan/models/abs.py new file mode 100644 index 0000000..05b470a --- /dev/null +++ b/src/magplan/models/abs.py @@ -0,0 +1,38 @@ +import typing as tp + +from django.contrib.sites.managers import CurrentSiteManager +from django.contrib.sites.models import Site +from django.db import models +from django.db.models import QuerySet + +from magplan.utils import current_site_id + +class AbstractBase(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + _old_id = models.PositiveIntegerField(null=True, blank=True) + + class Meta: + abstract = True + + +class AbstractSiteModel(models.Model): + """ + Support for multisite managers + """ + + site = models.ForeignKey( + Site, on_delete=models.CASCADE, default=current_site_id + ) + objects = models.Manager() + on_current_site = CurrentSiteManager() + + class Meta: + abstract = True + + @classmethod + def on_site(cls, site: tp.Optional[Site]) -> QuerySet: + if not site: + return cls.objects + + return cls.objects.filter(site=site) diff --git a/src/magplan/models/comment.py b/src/magplan/models/comment.py new file mode 100644 index 0000000..df38b5d --- /dev/null +++ b/src/magplan/models/comment.py @@ -0,0 +1,52 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import JSONField + +from magplan.models.abs import AbstractBase +from magplan.models.user import User +from magplan.xmd import render_md + + +class Comment(AbstractBase): + SYSTEM_ACTION_SET_STAGE = 5 + SYSTEM_ACTION_UPDATE = 10 + SYSTEM_ACTION_CHANGE_META = 15 + SYSTEM_ACTION_CHOICES = ( + (SYSTEM_ACTION_SET_STAGE, "Set stage"), + (SYSTEM_ACTION_UPDATE, "Update"), + (SYSTEM_ACTION_CHANGE_META, "Change meta"), + ) + + TYPE_SYSTEM = 5 + TYPE_PRIVATE = 10 + TYPE_PUBLIC = 15 + TYPE_CHOICES = ( + (TYPE_SYSTEM, "system"), + (TYPE_PRIVATE, "private"), + (TYPE_PUBLIC, "public"), + ) + text = models.TextField(blank=True) + type = models.SmallIntegerField(choices=TYPE_CHOICES, default=TYPE_PRIVATE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + commentable = GenericForeignKey("content_type", "object_id") + meta = JSONField(default=dict) + + def __str__(self): + return "%s, %s:%s..." % (self.user_id, self.type, self.text[0:50]) + + @property + def html(self): + return render_md(self.text, render_lead=False) + + @property + def changelog(self): + try: + md = "\n".join(self.meta["comment"]["changelog"]) + except Exception: + md = "" + + return render_md(md, render_lead=False) diff --git a/src/magplan/models/idea.py b/src/magplan/models/idea.py new file mode 100644 index 0000000..c3a4e02 --- /dev/null +++ b/src/magplan/models/idea.py @@ -0,0 +1,117 @@ +import os +import typing as tp + +import html2text +from django.contrib.contenttypes.fields import GenericRelation +from django.core.mail import EmailMultiAlternatives +from django.db import models +from django.template.loader import render_to_string + +from magplan.conf import settings as config +from magplan.models.abs import AbstractSiteModel, AbstractBase +from magplan.models.user import User +from magplan.xmd import render_md + + +NEW_IDEA_NOTIFICATION_PREFERENCE_NAME = "magplan__new_idea_notification" + +class Idea(AbstractSiteModel, AbstractBase): + AUTHOR_TYPE_NO = "NO" + AUTHOR_TYPE_NEW = "NW" + AUTHOR_TYPE_EXISTING = "EX" + AUTHOR_TYPE_CHOICES = [ + (AUTHOR_TYPE_NO, "Нет автора"), + (AUTHOR_TYPE_NEW, "Новый автор"), + (AUTHOR_TYPE_EXISTING, "Существующий автор(ы)"), + ] + title = models.CharField( + null=False, blank=False, max_length=255, verbose_name="Заголовок" + ) + description = models.TextField(verbose_name="Описание") + approved = models.BooleanField(null=True) + editor = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="editor" + ) + post = models.OneToOneField( + "Post", on_delete=models.SET_NULL, null=True, blank=True + ) + comments = GenericRelation("Comment") + author_type = models.CharField( + max_length=2, + choices=AUTHOR_TYPE_CHOICES, + default=AUTHOR_TYPE_NO, + verbose_name="Автор", + ) + authors_new = models.CharField( + max_length=255, null=True, blank=True, verbose_name="Новые автор" + ) + authors = models.ManyToManyField( + User, verbose_name="Авторы", related_name="authors", blank=True + ) + + def voted(self, user): + vote = next((v for v in self.votes.all() if v.user_id == user.id), None) + + if vote: + return True + return False + + def _send_vote_notification(self, recipient: User) -> None: + subject = f"Новая идея «{self.title}». Голосуйте!" + + context = {"idea": self, "APP_URL": os.environ.get("APP_URL")} + message_html_content: str = render_to_string( + "email/new_idea.html", context + ) + message_text_content: str = html2text.html2text(message_html_content) + + msg = EmailMultiAlternatives( + subject, + message_text_content, + config.PLAN_EMAIL_FROM, + [recipient.email], + ) + msg.attach_alternative(message_html_content, "text/html") + msg.send() + + def send_vote_notifications(self) -> None: + active_users: tp.List[User] = User.objects.filter( + is_active=True + ).exclude(id=self.editor_id) + recipients: tp.List[User] = [ + user + for user in active_users + if user.preferences[NEW_IDEA_NOTIFICATION_PREFERENCE_NAME] + ] + + for recipient in recipients: + self._send_vote_notification(recipient) + + def __str__(self): + return self.title + + class Meta: + permissions = ( + ("edit_extended_idea_attrs", "Edit extended Idea attributes"), + ("recieve_idea_email_updates", "Recieve email updates for Idea"), + ) + + @property + def comments_(self): + return self.comments.order_by("created_at").all + + @property + def score(self): + MAX_SCORE = 100 + + all_scores = sum([v.score for v in self.votes.all()]) + max_scores = len(self.votes.all()) * MAX_SCORE + + return round(all_scores / max_scores * 100) + + @property + def description_html(self): + return render_md(self.description) + + +NEW_IDEA_NOTIFICATION_PREFERENCE_NAME = "magplan__new_idea_notification" diff --git a/src/magplan/models/issue.py b/src/magplan/models/issue.py new file mode 100644 index 0000000..e327827 --- /dev/null +++ b/src/magplan/models/issue.py @@ -0,0 +1,26 @@ +import datetime + +from django.db import models + +from magplan.models.abs import AbstractSiteModel, AbstractBase +from magplan.models.magazine import Magazine + + +class Issue(AbstractSiteModel, AbstractBase): + class Meta: + ordering = ["-number"] + + def __str__(self): + return "%s #%s" % (self.magazine, self.number) + + number = models.SmallIntegerField(null=False, blank=False, default=0) + title = models.CharField(null=True, blank=False, max_length=255) + description = models.TextField(null=True, blank=False) + magazine = models.ForeignKey(Magazine, on_delete=models.CASCADE) + published_at = models.DateField( + null=False, blank=False, default=datetime.date.today + ) + + @property + def full_title(self) -> str: + return "{} #{} {}".format("Хакер", self.number, self.title or "") diff --git a/src/magplan/models/magazine.py b/src/magplan/models/magazine.py new file mode 100644 index 0000000..9029863 --- /dev/null +++ b/src/magplan/models/magazine.py @@ -0,0 +1,12 @@ +from django.db import models + +from magplan.models.abs import AbstractBase + + +class Magazine(AbstractBase): + slug = models.SlugField(null=False, blank=False, max_length=255) + title = models.CharField(null=False, blank=False, max_length=255) + description = models.TextField(null=False, blank=True) + + def __str__(self): + return self.title diff --git a/src/magplan/models.py b/src/magplan/models/post.py similarity index 52% rename from src/magplan/models.py rename to src/magplan/models/post.py index 6ca8edf..f3798ed 100644 --- a/src/magplan/models.py +++ b/src/magplan/models/post.py @@ -9,349 +9,32 @@ from typing import List import django -import html2text import requests from botocore.exceptions import ClientError -from django.conf import settings -from django.contrib.auth import get_user_model -from django.contrib.contenttypes.fields import ( - GenericForeignKey, - GenericRelation, -) -from django.contrib.contenttypes.models import ContentType -from django.contrib.sites.managers import CurrentSiteManager -from django.contrib.sites.models import Site -from django.core.mail import EmailMultiAlternatives +from django.contrib.contenttypes.fields import GenericRelation from django.db import models -from django.db.models import JSONField, Q, QuerySet -from django.db.models.signals import post_save, pre_save +from django.db.models import JSONField +from django.db.models.signals import pre_save from django.dispatch import receiver -from django.template.loader import render_to_string from django.utils import timezone -from dynamic_preferences.models import PerInstancePreferenceModel from magplan.conf import settings as config +from magplan.conf.settings import S3_STATIC_BASE_PATH from magplan.integrations.images import S3Client from magplan.integrations.posts import replace_images_paths, update_ext_db_xmd +from magplan.models import User +from magplan.models.abs import AbstractSiteModel, AbstractBase +from magplan.models.issue import Issue +from magplan.models.section import Section +from magplan.models.stage import Stage +from magplan.models.user import User from magplan.xmd import render_md -from magplan.xmd.mappers import s3_public_mapper as s3_image_mapper +from magplan.xmd.mappers import s3_public_mapper as s3_image_mapper, plan_internal_mapper as plan_image_mapper -NEW_IDEA_NOTIFICATION_PREFERENCE_NAME = "magplan__new_idea_notification" WP_DATE_FORMAT_STRING = "%Y-%m-%d %H:%M:%S" - -S3_STATIC_BASE_PATH = os.environ.get("S3_STATIC_BASE_PATH") - -from .xmd.mappers import plan_internal_mapper as plan_image_mapper - -UserModel = get_user_model() - logger = logging.getLogger() -class StorageType(enum.Enum): - S3 = 1 - - -class AbstractBase(models.Model): - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - _old_id = models.PositiveIntegerField(null=True, blank=True) - - class Meta: - abstract = True - - -def current_site_id() -> int: - return settings.SITE_ID - - -class AbstractSiteModel(models.Model): - """ - Support for multisite managers - """ - - site = models.ForeignKey( - Site, on_delete=models.CASCADE, default=current_site_id - ) - objects = models.Manager() - on_current_site = CurrentSiteManager() - - class Meta: - abstract = True - - @classmethod - def on_site(cls, site: tp.Optional[Site]) -> QuerySet: - if not site: - return cls.objects - - return cls.objects.filter(site=site) - - -class User(UserModel): - class Meta: - proxy = True - - meta = JSONField(default=dict) - - def __str__(self): - return self.display_name_default - - @property - def display_name_default(self): - p: Profile = self.profile - if p.l_name and p.f_name: - return "%s %s" % (p.f_name, p.l_name) - elif p.n_name: - return p.n_name - else: - return self.email - - @property - def display_name_generic(self): - p: Profile = self.profile - if p.l_name_generic and p.f_name_generic: - return "%s %s" % (p.f_name_generic, p.l_name_generic) - elif p.n_name: - return p.n_name - else: - return self.email - - @property - def str_reverse(self): - return self.__str__() - - @property - def str_employee(self): - return self.__str__() - - class Meta: - permissions = ( - ("access_magplan", "Can access magplan"), - ("manage_authors", "Can manage authors"), - ) - - def is_member(self, group_name: str) -> bool: - """Check if user is member of group - - :param group_name: Group name to check user belongs to - :return: True if a memeber, otherwise False - """ - return self.groups.filter(name=group_name).exists() - - -class Profile(AbstractBase): - is_public = models.BooleanField(null=False, blank=False, default=False) - user = models.OneToOneField( - User, on_delete=models.CASCADE, related_name="profile" - ) - f_name = models.CharField("Имя", max_length=255, blank=True, null=True) - m_name = models.CharField("Отчество", max_length=255, blank=True, null=True) - l_name = models.CharField("Фамилия", max_length=255, blank=True, null=True) - n_name = models.CharField("Ник", max_length=255, blank=True, null=True) - bio = models.TextField("Био", blank=True, null=True) - - # Global fields - f_name_generic = models.CharField( - "Имя латинницей", max_length=255, blank=True, null=True - ) - l_name_generic = models.CharField( - "Фамилия латинницей", max_length=255, blank=True, null=True - ) - bio_generic = models.TextField("Био латинницей", blank=True, null=True) - - RUSSIA = 0 - UKRAINE = 1 - BELARUS = 2 - KAZAKHSTAN = 3 - COUNTRY_CHOICES = ( - (RUSSIA, "Россия"), - (UKRAINE, "Украина"), - (BELARUS, "Беларусь"), - (KAZAKHSTAN, "Казахстан"), - ) - country = models.SmallIntegerField( - "Страна", choices=COUNTRY_CHOICES, default=RUSSIA - ) - city = models.CharField( - "Город или поселок", max_length=255, blank=True, null=True - ) - notes = models.TextField("Примечания", blank=True, null=True) - - -class Section(AbstractSiteModel, AbstractBase): - def __str__(self): - return self.title - - slug = models.SlugField(null=False, blank=False, max_length=255) - title = models.CharField(null=False, blank=False, max_length=255) - description = models.TextField(null=True, blank=False) - sort = models.SmallIntegerField(null=False, blank=False, default=0) - color = models.CharField( - null=False, blank=False, default="000000", max_length=6 - ) - is_archived = models.BooleanField(null=False, blank=False, default=False) - is_whitelisted = models.BooleanField(null=False, blank=False, default=False) - - -class Magazine(AbstractBase): - slug = models.SlugField(null=False, blank=False, max_length=255) - title = models.CharField(null=False, blank=False, max_length=255) - description = models.TextField(null=False, blank=True) - - def __str__(self): - return self.title - - -class Issue(AbstractSiteModel, AbstractBase): - class Meta: - ordering = ["-number"] - - def __str__(self): - return "%s #%s" % (self.magazine, self.number) - - number = models.SmallIntegerField(null=False, blank=False, default=0) - title = models.CharField(null=True, blank=False, max_length=255) - description = models.TextField(null=True, blank=False) - magazine = models.ForeignKey(Magazine, on_delete=models.CASCADE) - published_at = models.DateField( - null=False, blank=False, default=datetime.date.today - ) - - @property - def full_title(self) -> str: - return "{} #{} {}".format("Хакер", self.number, self.title or "") - - -class Stage(AbstractSiteModel, AbstractBase): - def __str__(self): - return self.title - - slug = models.SlugField(null=False, blank=False, max_length=255) - title = models.CharField(null=False, blank=False, max_length=255) - sort = models.SmallIntegerField(null=False, blank=False, default=0) - duration = models.SmallIntegerField(null=True, blank=True, default=1) - assignee = models.ForeignKey( - User, null=True, blank=True, on_delete=models.CASCADE - ) - prev_stage = models.ForeignKey( - "self", - related_name="n_stage", - null=True, - blank=True, - on_delete=models.CASCADE, - ) - next_stage = models.ForeignKey( - "self", - related_name="p_stage", - null=True, - blank=True, - on_delete=models.CASCADE, - ) - skip_notification = models.BooleanField( - null=False, blank=False, default=False - ) - meta = JSONField(default=dict) - - -class Idea(AbstractSiteModel, AbstractBase): - AUTHOR_TYPE_NO = "NO" - AUTHOR_TYPE_NEW = "NW" - AUTHOR_TYPE_EXISTING = "EX" - AUTHOR_TYPE_CHOICES = [ - (AUTHOR_TYPE_NO, "Нет автора"), - (AUTHOR_TYPE_NEW, "Новый автор"), - (AUTHOR_TYPE_EXISTING, "Существующий автор(ы)"), - ] - title = models.CharField( - null=False, blank=False, max_length=255, verbose_name="Заголовок" - ) - description = models.TextField(verbose_name="Описание") - approved = models.BooleanField(null=True) - editor = models.ForeignKey( - User, on_delete=models.CASCADE, related_name="editor" - ) - post = models.OneToOneField( - "Post", on_delete=models.SET_NULL, null=True, blank=True - ) - comments = GenericRelation("Comment") - author_type = models.CharField( - max_length=2, - choices=AUTHOR_TYPE_CHOICES, - default=AUTHOR_TYPE_NO, - verbose_name="Автор", - ) - authors_new = models.CharField( - max_length=255, null=True, blank=True, verbose_name="Новые автор" - ) - authors = models.ManyToManyField( - User, verbose_name="Авторы", related_name="authors", blank=True - ) - - def voted(self, user): - vote = next((v for v in self.votes.all() if v.user_id == user.id), None) - - if vote: - return True - return False - - def _send_vote_notification(self, recipient: User) -> None: - subject = f"Новая идея «{self.title}». Голосуйте!" - - context = {"idea": self, "APP_URL": os.environ.get("APP_URL")} - message_html_content: str = render_to_string( - "email/new_idea.html", context - ) - message_text_content: str = html2text.html2text(message_html_content) - - msg = EmailMultiAlternatives( - subject, - message_text_content, - config.PLAN_EMAIL_FROM, - [recipient.email], - ) - msg.attach_alternative(message_html_content, "text/html") - msg.send() - - def send_vote_notifications(self) -> None: - active_users: tp.List[User] = User.objects.filter( - is_active=True - ).exclude(id=self.editor_id) - recipients: tp.List[User] = [ - user - for user in active_users - if user.preferences[NEW_IDEA_NOTIFICATION_PREFERENCE_NAME] - ] - - for recipient in recipients: - self._send_vote_notification(recipient) - - def __str__(self): - return self.title - - class Meta: - permissions = ( - ("edit_extended_idea_attrs", "Edit extended Idea attributes"), - ("recieve_idea_email_updates", "Recieve email updates for Idea"), - ) - - @property - def comments_(self): - return self.comments.order_by("created_at").all - - @property - def score(self): - MAX_SCORE = 100 - - all_scores = sum([v.score for v in self.votes.all()]) - max_scores = len(self.votes.all()) * MAX_SCORE - - return round(all_scores / max_scores * 100) - - @property - def description_html(self): - return render_md(self.description) - - class Post(AbstractSiteModel, AbstractBase): # Used for external parser configuration PAYWALL_NOTICE_HEAD = '