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 = '
' @@ -634,10 +317,7 @@ def upload(self): * self content uploaded to WP with uploaded S3 images urls This method is long-running, can cause time-outs - and should be run ONLY in async tasks with post lock. - - with Lock(): - post.upload() + and should be run ONLY in async tasks. """ logger.debug("Staring Post.upload") if not self.wp_id: @@ -703,6 +383,65 @@ def render_xmd(self): self.is_paywalled = False +@receiver(pre_save, sender=Post) +def on_post_pre_save(sender, instance: Post, **kwargs): + instance.render_xmd() + + # If we're modifying existing object, not creating a new one + if not instance._state.adding: + if instance.features == Post.POST_FEATURES_ARCHIVE: + if instance.issues.count(): + target_issue: Issue = instance.issues.first() + + # Convert date to datetime + published_date: datetime.date = target_issue.published_at + published_datetime: datetime.datetime = ( + datetime.datetime.combine( + published_date, datetime.datetime.min.time() + ) + ) + instance.published_at = published_datetime + + +def _render_with_external_parser( + id: int, xmd: str, paywall_tag_html: str = Post.PAYWALL_NOTICE_RENDERED +) -> tp.Optional[str]: + FAILBACK_SYNTAX_LANG = "cpp" + + if not xmd: + return None + + if not config.EXTERNAL_PARSER_URL: + return None + + try: + request_payload: tp.Dict[str, str] = { + "id": id, + "md": xmd, + "lang": FAILBACK_SYNTAX_LANG, + "xakepcut": paywall_tag_html, + } + request_headers: tp.Dict[str, str] = { # unusued + "content-type": "application/x-www-form-urlencoded; charset=utf-8" + } + request_query_params: tp.Dict[str, str] = { + "x": config.EXTERNAL_PARSER_TOKEN or "" + } + + query_string = urllib.parse.urlencode(request_query_params) + prepared_url = f"{config.EXTERNAL_PARSER_URL}?{query_string}" + response = requests.post(prepared_url, data=request_payload) + return response.text + + except Exception as exc: + # TODO: add logger, no need to handle + return None + + +class StorageType(enum.Enum): + S3 = 1 + + class Attachment(AbstractBase): TYPE_IMAGE = 0 TYPE_PDF = 1 @@ -760,169 +499,7 @@ def _upload_to_s3(self): return True def upload_to_storage( - self, storage_type: StorageType = StorageType.S3 + self, storage_type: StorageType = StorageType.S3 ) -> None: if storage_type == StorageType.S3: self._upload_to_s3() - - -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) - - -class Vote(AbstractBase): - SCORE_0 = 0 - SCORE_25 = 25 - SCORE_50 = 50 - SCORE_75 = 75 - SCORE_100 = 100 - SCORE_CHOICES = ( - (SCORE_0, "Против таких статей в «Хакере»"), - (SCORE_25, "Не верю, что выйдет хорошо"), - (SCORE_50, "Тема нормальная, но не для меня"), - (SCORE_75, "Почитал бы, встретив в журнале"), - (SCORE_100, "Ради таких статей мог бы подписаться"), - ) - score = models.SmallIntegerField(choices=SCORE_CHOICES, default=SCORE_50) - user = models.ForeignKey(User, on_delete=models.CASCADE) - idea = models.ForeignKey( - Idea, on_delete=models.CASCADE, related_name="votes" - ) - - @property - def score_humanized(self): - return self.__class__.SCORE_CHOICES - - -def users_with_perm( - perm_name: str, include_superuser: bool = True -) -> List[User]: - """Get all users by full permission name - - :param perm_name: permission name without app name - :param include_superuser: - :return: - """ - return User.objects.filter( - Q(is_superuser=include_superuser) - | Q(user_permissions__codename=perm_name) - | Q(groups__permissions__codename=perm_name) - ).distinct() - - -@receiver(post_save, sender=User) -def create_user_profile(sender, instance, created, **kwargs): - if created: - Profile.objects.create(user=instance) - - -@receiver(post_save, sender=User) -def save_user_profile(sender, instance, **kwargs): - instance.profile.save() - - -def _render_with_external_parser( - id: int, xmd: str, paywall_tag_html: str = Post.PAYWALL_NOTICE_RENDERED -) -> tp.Optional[str]: - FAILBACK_SYNTAX_LANG = "cpp" - - if not xmd: - return None - - if not config.EXTERNAL_PARSER_URL: - return None - - try: - request_payload: tp.Dict[str, str] = { - "id": id, - "md": xmd, - "lang": FAILBACK_SYNTAX_LANG, - "xakepcut": paywall_tag_html, - } - request_headers: tp.Dict[str, str] = { # unusued - "content-type": "application/x-www-form-urlencoded; charset=utf-8" - } - request_query_params: tp.Dict[str, str] = { - "x": config.EXTERNAL_PARSER_TOKEN or "" - } - - query_string = urllib.parse.urlencode(request_query_params) - prepared_url = f"{config.EXTERNAL_PARSER_URL}?{query_string}" - response = requests.post(prepared_url, data=request_payload) - return response.text - - except Exception as exc: - # TODO: add logger, no need to handle - return None - - -@receiver(pre_save, sender=Post) -def on_post_pre_save(sender, instance: Post, **kwargs): - instance.render_xmd() - - # If we're modifying existing object, not creating a new one - if not instance._state.adding: - if instance.features == Post.POST_FEATURES_ARCHIVE: - if instance.issues.count(): - target_issue: Issue = instance.issues.first() - - # Convert date to datetime - published_date: datetime.date = target_issue.published_at - published_datetime: datetime.datetime = ( - datetime.datetime.combine( - published_date, datetime.datetime.min.time() - ) - ) - instance.published_at = published_datetime - - -class SitePreferenceModel(PerInstancePreferenceModel): - - instance = models.ForeignKey( - Site, on_delete=models.CASCADE, related_name="sites" - ) - - class Meta: - # Specifying the app_label here is mandatory for backward - # compatibility reasons, see #96 - app_label = "magplan" diff --git a/src/magplan/models/section.py b/src/magplan/models/section.py new file mode 100644 index 0000000..b24645c --- /dev/null +++ b/src/magplan/models/section.py @@ -0,0 +1,18 @@ +from django.db import models + +from magplan.models.abs import AbstractSiteModel, AbstractBase + + +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) diff --git a/src/magplan/models/site_preference_model.py b/src/magplan/models/site_preference_model.py new file mode 100644 index 0000000..bc18774 --- /dev/null +++ b/src/magplan/models/site_preference_model.py @@ -0,0 +1,15 @@ +from django.contrib.sites.models import Site +from django.db import models +from dynamic_preferences.models import PerInstancePreferenceModel + + +class SitePreferenceModel(PerInstancePreferenceModel): + + instance = models.ForeignKey( + Site, on_delete=models.CASCADE, related_name="sites" + ) + + class Meta: + # Specifying the app_label here is mandatory for backward + # compatibility reasons, see #96 + app_label = "magplan" diff --git a/src/magplan/models/stage.py b/src/magplan/models/stage.py new file mode 100644 index 0000000..80c5cf6 --- /dev/null +++ b/src/magplan/models/stage.py @@ -0,0 +1,36 @@ +from django.db import models +from django.db.models import JSONField + +from magplan.models.abs import AbstractSiteModel, AbstractBase +from magplan.models.user import User + + +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) diff --git a/src/magplan/models/user.py b/src/magplan/models/user.py new file mode 100644 index 0000000..8a6395a --- /dev/null +++ b/src/magplan/models/user.py @@ -0,0 +1,111 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models import JSONField +from django.db.models.signals import post_save +from django.dispatch import receiver + +from magplan.models.abs import AbstractBase + +UserModel = get_user_model() + + +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() + + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + if created: + Profile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + instance.profile.save() + + +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) diff --git a/src/magplan/models/vote.py b/src/magplan/models/vote.py new file mode 100644 index 0000000..dad00b5 --- /dev/null +++ b/src/magplan/models/vote.py @@ -0,0 +1,29 @@ +from django.db import models + +from magplan.models.abs import AbstractBase +from magplan.models.idea import Idea +from magplan.models.user import User + + +class Vote(AbstractBase): + SCORE_0 = 0 + SCORE_25 = 25 + SCORE_50 = 50 + SCORE_75 = 75 + SCORE_100 = 100 + SCORE_CHOICES = ( + (SCORE_0, "Против таких статей в «Хакере»"), + (SCORE_25, "Не верю, что выйдет хорошо"), + (SCORE_50, "Тема нормальная, но не для меня"), + (SCORE_75, "Почитал бы, встретив в журнале"), + (SCORE_100, "Ради таких статей мог бы подписаться"), + ) + score = models.SmallIntegerField(choices=SCORE_CHOICES, default=SCORE_50) + user = models.ForeignKey(User, on_delete=models.CASCADE) + idea = models.ForeignKey( + Idea, on_delete=models.CASCADE, related_name="votes" + ) + + @property + def score_humanized(self): + return self.__class__.SCORE_CHOICES diff --git a/src/magplan/utils.py b/src/magplan/utils.py index a4939ac..e48a60e 100644 --- a/src/magplan/utils.py +++ b/src/magplan/utils.py @@ -6,7 +6,7 @@ def safe_cast(value: Any, to: Callable, on_error: Any = None) -> Any: - """ "Safe casts value to type with provided casting constructor. + """Safe casts value to type with provided casting constructor. If cast falls for some reason, default on_error value is returned.* @@ -38,3 +38,7 @@ def get_current_site(request: HttpRequest, safe: bool = True) -> Site: # Should exists, don't handle exceptions return Site.objects.get(id=DEFAULT_SITE_ID) + + +def current_site_id() -> int: + return settings.SITE_ID diff --git a/src/magplan/views/posts.py b/src/magplan/views/posts.py index 83c176e..eb8659e 100644 --- a/src/magplan/views/posts.py +++ b/src/magplan/views/posts.py @@ -32,8 +32,8 @@ Post, Stage, User, - current_site_id, ) +from magplan.utils import current_site_id from magplan.tasks.send_post_comment_notification import ( send_post_comment_notification, )